PluginProbe
VikBooking Hotel Booking Engine & PMS / 1.8.16
VikBooking Hotel Booking Engine & PMS v1.8.16
1.8.16 1.8.15 1.8.14 1.8.13 1.8.12 1.8.11 1.8.10 1.8.9 1.8.6 1.8.7 1.8.8 trunk 1.6.0 1.6.1 1.6.2 1.6.3 1.6.4 1.6.5 1.6.6 1.6.7 1.6.8 1.6.9 1.7.0 1.7.1 1.7.2 All 37 releases
vikbooking / admin / helpers / src / model / reservation.php

reservation.php in VikBooking Hotel Booking Engine & PMS 1.8.16, at admin/helpers/src/model/reservation.php

4,046 lines 164.2 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * @package VikBooking
4 * @subpackage core
5 * @author E4J s.r.l.
6 * @copyright Copyright (C) 2023 E4J s.r.l. All Rights Reserved.
7 * @license http://www.gnu.org/licenses/gpl-2.0.html GNU/GPL
8 * @link https://vikwp.com
9 */
10
11 // No direct access
12 defined('ABSPATH') or die('No script kiddies please!');
13
14 /**
15 * VikBooking reservation model.
16 *
17 * @since 1.16.0 (J) - 1.6.0 (WP)
18 */
19 class VBOModelReservation extends JObject
20 {
21 /**
22 * The singleton instance of the class.
23 *
24 * @var VBOModelReservation
25 */
26 private static $instance = null;
27
28 /**
29 * The total number of bookings found through the last search.
30 *
31 * @var int
32 */
33 protected $totalBookings = 0;
34
35 /**
36 * @var array
37 */
38 protected $allRooms = [];
39
40 /**
41 * @var ?VBOBookingRegistry
42 */
43 protected ?VBOBookingRegistry $prevBookingRegistry = null;
44
45 /**
46 * Proxy for immediately getting the object and bind data.
47 *
48 * @param array|object $data optional data to bind.
49 * @param boolean $anew true for forcing a new instance.
50 *
51 * @return self
52 */
53 public static function getInstance($data = [], $anew = false)
54 {
55 if (is_null(static::$instance) || $anew) {
56 static::$instance = new static($data);
57 }
58
59 return static::$instance;
60 }
61
62 /**
63 * Sets the caller information used to save history records.
64 *
65 * @param string $caller The caller identifier.
66 *
67 * @return self
68 *
69 * @since 1.16.10 (J) - 1.6.10 (WP)
70 */
71 public function setCaller($caller = '')
72 {
73 $this->set('_caller', (string) $caller);
74
75 return $this;
76 }
77
78 /**
79 * Returns the caller information.
80 *
81 * @return string
82 *
83 * @since 1.16.10 (J) - 1.6.10 (WP)
84 */
85 public function getCaller()
86 {
87 return (string) $this->get('_caller', '');
88 }
89
90 /**
91 * Sets the history extra data value.
92 *
93 * @param array $data The history extra data array.
94 *
95 * @return self
96 *
97 * @since 1.16.10 (J) - 1.6.10 (WP)
98 */
99 public function setHistoryData(array $data = [])
100 {
101 $this->set('_historyData', $data);
102
103 return $this;
104 }
105
106 /**
107 * Returns the customer information.
108 *
109 * @return array
110 *
111 * @since 1.16.10 (J) - 1.6.10 (WP)
112 */
113 public function getHistoryData()
114 {
115 return (array) $this->get('_historyData', []);
116 }
117
118 /**
119 * Sets the search filters.
120 *
121 * @param array $data The search filters associative array.
122 *
123 * @return self
124 *
125 * @since 1.16.10 (J) - 1.6.10 (WP)
126 */
127 public function setFilters(array $data = [])
128 {
129 $this->set('_filters', $data);
130
131 return $this;
132 }
133
134 /**
135 * Returns the search filters.
136 *
137 * @return array
138 *
139 * @since 1.16.10 (J) - 1.6.10 (WP)
140 */
141 public function getFilters()
142 {
143 return (array) $this->get('_filters', []);
144 }
145
146 /**
147 * Sets the booking information record.
148 *
149 * @param array $booking The booking record.
150 *
151 * @return self
152 *
153 * @since 1.16.10 (J) - 1.6.10 (WP)
154 */
155 public function setBooking(array $booking = [])
156 {
157 $this->set('_booking', $booking);
158
159 return $this;
160 }
161
162 /**
163 * Returns the booking information record.
164 *
165 * @return array
166 *
167 * @since 1.16.10 (J) - 1.6.10 (WP)
168 */
169 public function getBooking()
170 {
171 return (array) $this->get('_booking', []);
172 }
173
174 /**
175 * Sets the room booking records.
176 *
177 * @param array $room_booking The room booking records.
178 *
179 * @return self
180 *
181 * @since 1.16.10 (J) - 1.6.10 (WP)
182 */
183 public function setRoomBooking(array $room_booking = [])
184 {
185 $this->set('_roomBooking', $room_booking);
186
187 return $this;
188 }
189
190 /**
191 * Returns the room booking records.
192 *
193 * @return array
194 *
195 * @since 1.16.10 (J) - 1.6.10 (WP)
196 */
197 public function getRoomBooking()
198 {
199 return (array) $this->get('_roomBooking', []);
200 }
201
202 /**
203 * Sets the customer information.
204 *
205 * @param array $customer the customer array.
206 *
207 * @return self
208 */
209 public function setCustomer(array $customer = [])
210 {
211 $this->set('_customer', $customer);
212
213 return $this;
214 }
215
216 /**
217 * Returns the customer information.
218 *
219 * @return array
220 */
221 public function getCustomer()
222 {
223 return (array) $this->get('_customer', []);
224 }
225
226 /**
227 * Sets a list of rooms data for a multi-room context.
228 *
229 * @param array $rooms List of rooms data arrays.
230 *
231 * @return self
232 *
233 * @since 1.18.8 (J) - 1.8.8 (WP)
234 */
235 public function setRooms(array $rooms)
236 {
237 // set rooms list data
238 $this->set('_rooms', $rooms);
239
240 if (!$this->getRoom() && ($rooms[0] ?? [])) {
241 // populate data also for the current/first room
242 $this->set('_room', (array) $rooms[0]);
243 }
244
245 return $this;
246 }
247
248 /**
249 * Sets the room booking information for both single and multi room context.
250 *
251 * @param array $room The room data array.
252 * @param ?int $index Optional room index to update in a multi-room context.
253 *
254 * @return self
255 *
256 * @since 1.18.8 (J) - 1.8.8 (WP) added argument $index.
257 */
258 public function setRoom(array $room, ?int $index = null)
259 {
260 // set current room data
261 $this->set('_room', $room);
262
263 // access all rooms previously set
264 $rooms = $this->getRooms();
265
266 if ($index !== null && isset($rooms[$index])) {
267 // overwrite requested room index
268 $rooms[$index] = $room;
269 $this->set('_rooms', $rooms);
270 } elseif ($index === null) {
271 // push room data to list
272 $rooms[] = $room;
273 $this->set('_rooms', $rooms);
274 }
275
276 return $this;
277 }
278
279 /**
280 * Returns the information of all rooms set. This works
281 * for both single-room and multi-room booking context.
282 *
283 * @return array
284 *
285 * @since 1.18.8 (J) - 1.8.8 (WP)
286 */
287 public function getRooms()
288 {
289 return (array) $this->get('_rooms', []);
290 }
291
292 /**
293 * Returns the current room information.
294 *
295 * @return array
296 */
297 public function getRoom()
298 {
299 return (array) $this->get('_room', []);
300 }
301
302 /**
303 * Tells if we are dealing with a multi-room booking.
304 *
305 * @return bool
306 *
307 * @since 1.18.8 (J) - 1.8.8 (WP)
308 */
309 public function isMultiRoom()
310 {
311 return (bool) (count($this->getRooms()) > 1);
312 }
313
314 /**
315 * Sets the new booking ID created.
316 *
317 * @param int $bid the newly added record ID.
318 *
319 * @return self
320 */
321 protected function setNewBookingID($bid = 0)
322 {
323 $this->set('_newBookingID', $bid);
324
325 return $this;
326 }
327
328 /**
329 * Returns the new booking ID created, or 0.
330 *
331 * @return int
332 */
333 public function getNewBookingID()
334 {
335 return (int) $this->get('_newBookingID', 0);
336 }
337
338 /**
339 * Sets the VCM action to be performed in order to sync the availability.
340 *
341 * @param string $action the VCM action, usually an HTML link.
342 *
343 * @return self
344 */
345 protected function setChannelManagerAction($action = '')
346 {
347 $this->set('_vcmAction', $action);
348
349 return $this;
350 }
351
352 /**
353 * Returns the VCM action (if any) to sync the availability.
354 *
355 * @return string
356 */
357 public function getChannelManagerAction()
358 {
359 return $this->get('_vcmAction', '');
360 }
361
362 /**
363 * Sets the check-in and check-out times with hours and minutes.
364 *
365 * @return array list of check-in and check-out hours and minutes.
366 *
367 * @since 1.18.8 (J) - 1.8.8 (WP) added support to room-level times.
368 * removed static cache because the object could be used multiple times.
369 */
370 public function loadCheckinOutTimes()
371 {
372 $timeopst = VikBooking::getTimeOpenStore();
373 if (is_array($timeopst) && $timeopst) {
374 $opent = VikBooking::getHoursMinutes($timeopst[0]);
375 $closet = VikBooking::getHoursMinutes($timeopst[1]);
376 $hcheckin = $opent[0];
377 $mcheckin = $opent[1];
378 $hcheckout = $closet[0];
379 $mcheckout = $closet[1];
380 } else {
381 $hcheckin = 0;
382 $mcheckin = 0;
383 $hcheckout = 0;
384 $mcheckout = 0;
385 }
386
387 // set global check-in/check-out hours and minutes
388 $this->set('checkin_h', $hcheckin);
389 $this->set('checkin_m', $mcheckin);
390 $this->set('checkout_h', $hcheckout);
391 $this->set('checkout_m', $mcheckout);
392
393 // calculate room-level check-in/check-out hours and minutes
394 $involvedRoomIds = array_map('intval', array_values(array_unique(array_column($this->getRooms(), 'id'))));
395 $roomParamsAssoc = array_combine($involvedRoomIds, array_map(function($roomId) {
396 return VikBooking::getRoomInfo($roomId, ['params'], true)['params'] ?? '';
397 }, $involvedRoomIds));
398 foreach ($roomParamsAssoc as $roomId => $roomParams) {
399 $customCheckin = VikBooking::getRoomParam('checkin', $roomParams);
400 $customCheckout = VikBooking::getRoomParam('checkout', $roomParams);
401 if ($customCheckin) {
402 // set proper check-in time at room-level
403 $parts = explode(':', $customCheckin);
404 $this->set($roomId . '_checkin_h', (int) $parts[0]);
405 $this->set($roomId . '_checkin_m', (int) ($parts[1] ?? 0));
406 }
407 if ($customCheckout) {
408 // set proper check-out time at room-level
409 $parts = explode(':', $customCheckout);
410 $this->set($roomId . '_checkout_h', (int) $parts[0]);
411 $this->set($roomId . '_checkout_m', (int) ($parts[1] ?? 0));
412 }
413 }
414
415 // return the linear list
416 return [
417 $hcheckin,
418 $mcheckin,
419 $hcheckout,
420 $mcheckout,
421 ];
422 }
423
424 /**
425 * Attempts to extract the Special Requests from the customer raw data.
426 *
427 * @return string
428 *
429 * @since 1.16.5 (J) - 1.6.5 (WP)
430 */
431 public function extractSpecialRequests()
432 {
433 $raw_cust_data = $this->get('custdata', '');
434 if (empty($raw_cust_data)) {
435 return '';
436 }
437
438 $special_requests = '';
439 if (preg_match("/(?:special_?requests:\s*)(.*?)$/is", $raw_cust_data, $match)) {
440 $special_requests = $match[1];
441 } elseif (preg_match("/(?:special_?request:\s*)(.*?)$/is", $raw_cust_data, $match)) {
442 $special_requests = $match[1];
443 } elseif (preg_match("/(?:special_?request\s*)(.*?)$/is", $raw_cust_data, $match)) {
444 $special_requests = $match[1];
445 } elseif (preg_match("/(?:" . JText::translate('ORDER_SPREQUESTS') . ":\s*)(.*?)$/is", $raw_cust_data, $match)) {
446 $special_requests = $match[1];
447 }
448
449 return $special_requests;
450 }
451
452 /**
453 * Runs before a reservation is created or updated.
454 *
455 * @param ?int $bookingId Optional reservation ID being updated.
456 *
457 * @return bool
458 *
459 * @since 1.18.11 (J) - 1.8.11 (WP)
460 */
461 protected function preflight(?int $bookingId = null)
462 {
463 // if updating a reservation, fetch the previous details
464 try {
465 $prevBookingRegistry = $bookingId ? VBOBookingRegistry::getInstance(['id' => $bookingId]) : null;
466 } catch (Exception $e) {
467 $this->setError($e->getMessage());
468 return false;
469 }
470
471 if ($prevBookingRegistry) {
472 // set booking registry snapshot prior to update
473 $this->prevBookingRegistry = $prevBookingRegistry;
474 }
475
476 // availability helper
477 $av_helper = VikBooking::getAvailabilityInstance(true);
478
479 // validate mandatory fields
480 $room = $this->getRoom();
481 if (!$this->get('checkin') || !$this->get('checkout') || empty($room['id'])) {
482 $this->setError('Missing mandatory fields');
483 return false;
484 }
485
486 if ($this->get('checkin') >= $this->get('checkout')) {
487 $this->setError('Invalid dates');
488 return false;
489 }
490
491 // make sure we have the time for check-in and check-out
492 if (!$this->get('checkin_h') || !$this->get('checkout_h')) {
493 // make sure to set the times (hours/minutes) for check-in and check-out
494 $this->loadCheckinOutTimes();
495
496 // load default check-in/check-out times
497 $defCheckinHour = $this->get('checkin_h');
498 $defCheckinMin = $this->get('checkin_m');
499 $defCheckoutHour = $this->get('checkout_h');
500 $defCheckoutMin = $this->get('checkout_m');
501
502 if (!$this->isMultiRoom()) {
503 // check if the only room we are booking has got check-in/check-out times at room-level
504 $defCheckinHour = $this->get($room['id'] . '_checkin_h', $defCheckinHour);
505 $defCheckinMin = $this->get($room['id'] . '_checkin_m', $defCheckinMin);
506 $defCheckoutHour = $this->get($room['id'] . '_checkout_h', $defCheckoutHour);
507 $defCheckoutMin = $this->get($room['id'] . '_checkout_m', $defCheckoutMin);
508 }
509
510 // make sure check-in and check-out timestamps have been set to a proper time
511 $from_info = getdate($this->get('checkin'));
512 $to_info = getdate($this->get('checkout'));
513 if ((int) $from_info['hours'] != (int) $defCheckinHour) {
514 $this->set('checkin', mktime((int) $defCheckinHour, (int) $defCheckinMin, 0, $from_info['mon'], $from_info['mday'], $from_info['year']));
515 }
516 if ((int) $to_info['hours'] != (int) $defCheckoutHour) {
517 $this->set('checkout', mktime((int) $defCheckoutHour, (int) $defCheckoutMin, 0, $to_info['mon'], $to_info['mday'], $to_info['year']));
518 }
519
520 if ($this->isMultiRoom()) {
521 // apply check-in and check-out times at room-level in case of different stay dates
522 foreach ($this->getRooms() as $index => $roomData) {
523 // default values at booking-level
524 $room_checkin_ts = $this->get('checkin');
525 $room_checkout_ts = $this->get('checkout');
526 if (!empty($roomData['checkin']) && !is_numeric($roomData['checkin'])) {
527 // convert room-level stay dates into timestamps
528 $room_checkin_ts = strtotime($roomData['checkin']);
529 $room_checkout_ts = strtotime($roomData['checkout']);
530 }
531 // gather global or room-level check-in/check-out hours and minutes
532 $useCheckinHour = $this->get($roomData['id'] . '_checkin_h', $defCheckinHour);
533 $useCheckinMin = $this->get($roomData['id'] . '_checkin_m', $defCheckinMin);
534 $useCheckoutHour = $this->get($roomData['id'] . '_checkout_h', $defCheckoutHour);
535 $useCheckoutMin = $this->get($roomData['id'] . '_checkout_m', $defCheckoutMin);
536 // calculate room-level stay dates and nights of stay
537 $roomData['checkin'] = strtotime(sprintf('%d:%d', (int) $useCheckinHour, (int) $useCheckinMin), $room_checkin_ts);
538 $roomData['checkout'] = strtotime(sprintf('%d:%d', (int) $useCheckoutHour, (int) $useCheckoutMin), $room_checkout_ts);
539 $roomData['nights'] = $av_helper->countNightsOfStay($roomData['checkin'], $roomData['checkout']);
540 // update room data
541 $this->setRoom($roomData, $index);
542 }
543 }
544 }
545
546 // number of nights of stay
547 if (!$this->get('nights')) {
548 $this->set('nights', $av_helper->countNightsOfStay($this->get('checkin'), $this->get('checkout')));
549 }
550
551 // fetch and apply turnover time before doing anything else
552 $this->applyTurnover();
553
554 return true;
555 }
556
557 /**
558 * Creates a new reservation record after having constructed the
559 * object by properly injecting all the necessary booking information.
560 *
561 * @return bool
562 */
563 public function create()
564 {
565 if (!$this->canCreate()) {
566 $this->setError('Forbidden');
567 return false;
568 }
569
570 /**
571 * Run preflight to ensure data integrity.
572 *
573 * @since 1.18.11 (J) - 1.8.11 (WP)
574 */
575 if (!$this->preflight()) {
576 return false;
577 }
578
579 // get pool of rooms involved
580 $rooms_pool = $this->getRoomsPool();
581 if (!$rooms_pool) {
582 if ($this->getError() === false) {
583 // set generic error if not set already
584 $this->setError('No rooms involved in the reservation');
585 }
586 return false;
587 }
588
589 // check if the rooms are available
590 $rooms_available = $this->checkRoomsAvailability($rooms_pool);
591 if (!$rooms_available && !$this->get('force_booking', 0) && !$this->get('set_closed', 0)) {
592 // no forcing, no closure and room(s) fully booked means we have an error
593 if (!$this->getError()) {
594 $this->setError(JText::translate('VBBOOKNOTMADE'));
595 }
596 return false;
597 }
598
599 // detect if we are forcing the reservation
600 $this->detectForcedReason($rooms_available);
601
602 // store the customer information
603 $this->storeCustomer();
604
605 // calculate total amount and total tax
606 $this->calculateTotal();
607
608 // store booking and room-booking records
609 if (!$this->storeReservationRecords($rooms_pool)) {
610 if ($this->getError() === false) {
611 // set generic error if not set already
612 $this->setError('Could not create the reservation');
613 }
614 return false;
615 }
616
617 return true;
618 }
619
620 /**
621 * Updates a reservation record after having constructed the
622 * object by properly injecting all the necessary booking information.
623 * Unlike the modify() method, this method follows the create() pattern.
624 *
625 * @return bool
626 *
627 * @since 1.18.11 (J) - 1.8.11 (WP)
628 */
629 public function update()
630 {
631 // access booking ID
632 $bookingId = (int) $this->get('booking_id', 0);
633
634 if (!$bookingId) {
635 $this->setError('Missing booking ID to update.');
636 return false;
637 }
638
639 /**
640 * Run preflight to ensure data integrity.
641 */
642 if (!$this->preflight($bookingId)) {
643 return false;
644 }
645
646 // get pool of rooms involved
647 $rooms_pool = $this->getRoomsPool();
648 if (!$rooms_pool) {
649 if ($this->getError() === false) {
650 // set generic error if not set already
651 $this->setError('No rooms involved in the reservation');
652 }
653 return false;
654 }
655
656 try {
657 // check if the rooms are available for modification
658 $rooms_available = $this->bookingModifiable($bookingId, $this->get('checkin'), $this->get('checkout'));
659 if (!$rooms_available && !$this->get('force_booking', 0) && !$this->get('set_closed', 0)) {
660 // no forcing, no closure and room(s) fully booked means we have an error
661 if (!$this->getError()) {
662 $this->setError(JText::translate('VBBOOKNOTMADE'));
663 }
664 return false;
665 }
666 } catch (Exception $e) {
667 $this->setError($e->getMessage());
668 return false;
669 }
670
671 // detect if we are forcing the reservation
672 $this->detectForcedReason($rooms_available);
673
674 // store or update the customer information
675 $this->storeCustomer();
676
677 // calculate total amount and total tax
678 $this->calculateTotal();
679
680 // update booking and room-booking records
681 if (!$this->storeReservationRecords($rooms_pool, $bookingId)) {
682 if ($this->getError() === false) {
683 // set generic error if not set already
684 $this->setError('Could not update the reservation');
685 }
686 return false;
687 }
688
689 return true;
690 }
691
692 /**
693 * Searches for bookings according to specified filters.
694 *
695 * @return array
696 *
697 * @since 1.16.10 (J) - 1.6.10 (WP)
698 * @since 1.18.3 (J) - 1.8.3 (WP) added support for "ota_id" and "channel" filters.
699 */
700 public function search()
701 {
702 $dbo = JFactory::getDbo();
703
704 $this->totalBookings = 0;
705
706 $filters = $this->getFilters();
707
708 if (!$filters) {
709 $this->setError('Missing filters to search for a booking.');
710 return [];
711 }
712
713 $q = $dbo->getQuery(true)
714 ->select($dbo->qn('o') . '.*')
715 ->select([
716 $dbo->qn('c.first_name', 'customer_first_name'),
717 $dbo->qn('c.last_name', 'customer_last_name'),
718 ])
719 ->from($dbo->qn('#__vikbooking_orders', 'o'))
720 ->leftJoin($dbo->qn('#__vikbooking_customers_orders', 'co') . ' ON ' . $dbo->qn('co.idorder') . ' = ' . $dbo->qn('o.id'))
721 ->leftJoin($dbo->qn('#__vikbooking_customers', 'c') . ' ON ' . $dbo->qn('c.id') . ' = ' . $dbo->qn('co.idcustomer'))
722 ->where(1);
723
724 if (($filters['booking_id'] ?? null)) {
725 $q->andWhere([
726 $dbo->qn('o.id') . ' = ' . $dbo->q($filters['booking_id']),
727 $dbo->qn('o.idorderota') . ' = ' . $dbo->q($filters['booking_id']),
728 ], $glue = 'OR');
729 }
730
731 if (($filters['ota_id'] ?? null)) {
732 $q->where($dbo->qn('o.idorderota') . ' = ' . $dbo->q($filters['ota_id']));
733 if (($filters['channel'] ?? null)) {
734 $q->where($dbo->qn('o.channel') . ' LIKE ' . (strpos($filters['channel'], '%') !== false ? $dbo->q($filters['channel']) : $dbo->q('%' . $filters['channel'] . '%')));
735 } else {
736 $q->where($dbo->qn('o.channel') . ' IS NOT NULL');
737 }
738 }
739
740 if (($filters['status'] ?? null)) {
741 $q->where($dbo->qn('o.status') . ' = ' . $dbo->q($filters['status']));
742 }
743
744 if (($filters['exclude_closures'] ?? false)) {
745 $q->where($dbo->qn('o.closure') . ' = 0');
746 }
747
748 if (($filters['exclude_expired'] ?? false)) {
749 // take only active reservations with a check-out date in the future
750 $today_dt = JFactory::getDate('today', new DateTimeZone(date_default_timezone_get()));
751 $q->where($dbo->qn('o.checkout') . ' >= ' . $dbo->q($today_dt->format('U', true)));
752 }
753
754 if (($filters['email'] ?? null)) {
755 $q->where($dbo->qn('o.custmail') . ' = ' . $dbo->q($filters['email']));
756 }
757
758 if (($filters['phone'] ?? null)) {
759 $q->where(
760 sprintf(
761 '(%s OR %s)',
762 sprintf('REPLACE(%s, \' \', \'\') LIKE REPLACE(%s, \' \', \'\')',
763 $dbo->qn('o.phone'),
764 $dbo->q('%' . $filters['phone'])
765 ),
766 sprintf('REPLACE(%s, \' \', \'\') LIKE REPLACE(%s, \' \', \'\')',
767 $dbo->qn('o.phone_alias'),
768 $dbo->q('%' . $filters['phone_alias'])
769 )
770 )
771 );
772 }
773
774 if (($filters['date_range']['type'] ?? null) && (($filters['date_range']['start'] ?? null) || ($filters['date_range']['end'] ?? null))) {
775 // search by date range
776 $from_dt = JFactory::getDate(($filters['date_range']['start'] ?? $filters['date_range']['end']));
777 $from_dt->modify('00:00:00');
778 $to_dt = JFactory::getDate(($filters['date_range']['end'] ?? $filters['date_range']['start']));
779 $to_dt->modify('23:59:59');
780
781 // check the type of date
782 if ($filters['date_range']['type'] == 'stay') {
783 // find intersections of stay dates
784 $q->andWhere([
785 '(' . $dbo->qn('o.checkin') . ' <= ' . $dbo->q($from_dt->format('U')) . ' AND ' . $dbo->qn('o.checkout') . ' >= ' . $dbo->q($to_dt->format('U')) . ')',
786 '(' . $dbo->qn('o.checkin') . ' >= ' . $dbo->q($from_dt->format('U')) . ' AND ' . $dbo->qn('o.checkout') . ' <= ' . $dbo->q($to_dt->format('U')) . ')',
787 '(' . $dbo->qn('o.checkin') . ' >= ' . $dbo->q($from_dt->format('U')) . ' AND ' . $dbo->qn('o.checkin') . ' < ' . $dbo->q($to_dt->format('U')) . ' AND ' . $dbo->qn('o.checkout') . ' >= ' . $dbo->q($to_dt->format('U')) . ')',
788 '(' . $dbo->qn('o.checkin') . ' <= ' . $dbo->q($from_dt->format('U')) . ' AND ' . $dbo->qn('o.checkout') . ' > ' . $dbo->q($from_dt->format('U')) . ' AND ' . $dbo->qn('o.checkout') . ' <= ' . $dbo->q($to_dt->format('U')) . ')',
789 ], $glue = 'OR');
790 } else {
791 $column = $dbo->qn('o.checkin');
792 if ($filters['date_range']['type'] == 'checkout') {
793 $column = $dbo->qn('o.checkout');
794 } elseif ($filters['date_range']['type'] == 'creation') {
795 $column = $dbo->qn('o.ts');
796 }
797 $q->where($column . ' >= ' . $dbo->q($from_dt->format('U')));
798 $q->where($column . ' <= ' . $dbo->q($to_dt->format('U')));
799 }
800 } else {
801 // check for single date filters
802 if (($filters['creation_date'] ?? null)) {
803 // dates are expected to be in military format
804 $creation = JFactory::getDate($filters['creation_date']);
805 $creation->modify('00:00:00');
806 $q->where($dbo->qn('o.ts') . ' >= ' . $dbo->q($creation->format('U')));
807 $creation->modify('23:59:59');
808 $q->where($dbo->qn('o.ts') . ' <= ' . $dbo->q($creation->format('U')));
809 }
810
811 if (($filters['checkin_date'] ?? null) && !($filters['checkout_date'] ?? null)) {
812 // dates are expected to be in military format
813 $checkin = JFactory::getDate($filters['checkin_date']);
814 $checkin->modify('00:00:00');
815 $q->where($dbo->qn('o.checkin') . ' >= ' . $dbo->q($checkin->format('U')));
816 $checkin->modify('23:59:59');
817 $q->where($dbo->qn('o.checkin') . ' <= ' . $dbo->q($checkin->format('U')));
818 }
819
820 if (($filters['checkout_date'] ?? null) && !($filters['checkin_date'] ?? null)) {
821 // dates are expected to be in military format
822 $checkout = JFactory::getDate($filters['checkout_date']);
823 $checkout->modify('00:00:00');
824 $q->where($dbo->qn('o.checkout') . ' >= ' . $dbo->q($checkout->format('U')));
825 $checkout->modify('23:59:59');
826 $q->where($dbo->qn('o.checkout') . ' <= ' . $dbo->q($checkout->format('U')));
827 }
828
829 if (($filters['checkin_date'] ?? null) && ($filters['checkout_date'] ?? null)) {
830 // range of dates (dates are expected to be in military format)
831 $checkin = JFactory::getDate($filters['checkin_date']);
832 $checkin->modify('00:00:00');
833 $checkout = JFactory::getDate($filters['checkout_date']);
834 $checkout->modify('23:59:59');
835 $q->andWhere([
836 $dbo->qn('o.checkin') . ' BETWEEN ' . $dbo->q($checkin->format('U')) . ' AND ' . $dbo->q($checkout->format('U')),
837 $dbo->qn('o.checkout') . ' BETWEEN ' . $dbo->q($checkin->format('U')) . ' AND ' . $dbo->q($checkout->format('U')),
838 ], $glue = 'OR');
839 }
840
841 if (($filters['stay_date'] ?? null)) {
842 // dates are expected to be in military format
843 $staydt = JFactory::getDate($filters['stay_date']);
844 $staydt->modify('23:59:59');
845 $q->where($dbo->qn('o.checkin') . ' < ' . $dbo->q($staydt->format('U')));
846 $q->where($dbo->qn('o.checkout') . ' > ' . $dbo->q($staydt->format('U')));
847 }
848 }
849
850 if (($filters['customer_name'] ?? null)) {
851 $q->where('CONCAT_WS(\' \', ' . $dbo->qn('c.first_name') . ', ' . $dbo->qn('c.last_name') . ') LIKE ' . $dbo->q('%' . $filters['customer_name'] . '%'));
852 }
853
854 if (($filters['confirmation_number'] ?? null)) {
855 $q->where($dbo->qn('o.confirmnumber') . ' = ' . $dbo->q($filters['confirmation_number']));
856 }
857
858 if (($filters['room_name'] ?? null)) {
859 // find the room involved from the given name
860 $room_record = VikBooking::getAvailabilityInstance()->getRoomByName($filters['room_name']);
861 if ($room_record) {
862 $q->leftJoin($dbo->qn('#__vikbooking_ordersrooms', 'or') . ' ON ' . $dbo->qn('or.idorder') . ' = ' . $dbo->qn('o.id'));
863 $q->where($dbo->qn('or.idroom') . ' = ' . (int) $room_record['id']);
864 }
865 }
866
867 /**
868 * It is now possible to use a custom ordering.
869 *
870 * @since 1.17.1 (J) - 1.7.1 (WP)
871 */
872 switch ($filters['ordering'] ?? 'id') {
873 case 'creation': $ordering = 'o.ts'; break;
874 case 'checkin': $ordering = 'o.checkin'; break;
875 case 'checkout': $ordering = 'o.checkout'; break;
876 default: $ordering = 'o.id';
877 }
878
879 $q->order($dbo->qn($ordering) . ' ' . (strcasecmp($filters['direction'] ?? 'desc', 'desc') ? 'ASC' : 'DESC'));
880
881 $dbo->setQuery($q, 0, ($filters['max_bookings'] ?? 0));
882 $rows = $dbo->loadAssocList();
883
884 $this->totalBookings = count($rows);
885
886 /**
887 * Calculate the total number of matching records.
888 *
889 * @since 1.17.1 (J) - 1.7.1 (WP)
890 */
891 if ($this->totalBookings && $this->totalBookings == ($filters['max_bookings'] ?? 0)) {
892 // set up the query used to count the matching records
893 $dbo->setQuery($q->clear('select')->clear('offset')->clear('limit')->select('COUNT(1)'));
894 $this->totalBookings = (int) $dbo->loadResult();
895 }
896
897 return $rows;
898 }
899
900 /**
901 * Returns the total number of bookings matching the last search query made.
902 *
903 * @return int
904 *
905 * @since 1.17.1 (J) - 1.7.1 (WP)
906 */
907 public function getTotBookingsFound()
908 {
909 return $this->totalBookings;
910 }
911
912 /**
913 * Updates the total amount paid and the amount of (OTA) compensation. To be used for
914 * OTA reservations only, previously validated. Runs often after an OTA notification.
915 *
916 * @param array $options Associative list of booking values to update.
917 * @param array $booking Optional associative booking record to update.
918 *
919 * @return bool
920 *
921 * @since 1.18.3 (J) - 1.8.3 (WP)
922 */
923 public function updatePayoutCompensation(array $options, array $booking = [])
924 {
925 $dbo = JFactory::getDbo();
926
927 if ($booking) {
928 // update the internal reference
929 $this->setBooking($booking);
930 } else {
931 // get the internal booking record
932 $booking = $this->getBooking();
933 }
934
935 if (empty($booking['id'])) {
936 // no booking information available
937 return false;
938 }
939
940 // build booking record values to update
941 $booking_record = new stdClass;
942 $booking_record->id = $booking['id'];
943
944 // access the booking history object
945 $history_obj = VikBooking::getBookingHistoryInstance($booking['id']);
946
947 // flag/counter that indicates if any booking record value was updated
948 $booking_values_counter = 0;
949
950 // check if a payout should be set
951 if ($options['payout'] ?? null) {
952 // update the booking total amount paid and register a payout received event
953 $booking_record->totpaid = (float) $options['payout'];
954
955 // update cached booking information for history
956 $booking['totpaid'] = (float) $options['payout'];
957 $history_obj->setBookingInfo($booking);
958
959 // the payout received event type
960 $ev_type = 'PO';
961
962 // load current history
963 $history_rows = array_reverse($history_obj->loadHistory());
964
965 // check if there is a valid event with an amount paid greater than zero
966 $prev_paid = 0;
967 foreach ($history_rows as $hevent) {
968 if ($hevent['totpaid'] < 1 || $hevent['totpaid'] == $options['payout'] || $hevent['type'] == $ev_type) {
969 // skip history records with no amount paid, amount paid equal to payout, or payout events
970 continue;
971 }
972 // amount paid found before payout notification
973 $prev_paid = $hevent['totpaid'];
974 break;
975 }
976
977 // sum any previously paid amount
978 $booking_record->totpaid += $prev_paid;
979
980 // update booking record in VBO
981 if ($dbo->updateObject('#__vikbooking_orders', $booking_record, 'id')) {
982 // increase counter
983 $booking_values_counter++;
984 }
985
986 // build event description
987 $ev_descr = preg_replace('/^[^_]*_/', '', (string) ($booking['channel'] ?? '')) . ': ' . $options['payout'];
988
989 // set event extra data
990 $history_obj->setExtraData([
991 // this is the total amount paid up until now
992 'payout_total' => (float) $options['payout'],
993 ]);
994
995 // silence the notification center
996 $history_obj->setSilentNotificationCenter(true);
997
998 // store event history log
999 $history_obj->store($ev_type, $ev_descr);
1000 }
1001
1002 // check if the commission (compensation) amount should be set
1003 if ($options['compensation'] ?? null) {
1004 // update the total commissions amount
1005 $booking_record->cmms = (float) $options['compensation'];
1006
1007 // update cached booking information for history
1008 $booking['cmms'] = (float) $options['compensation'];
1009 $history_obj->setBookingInfo($booking);
1010
1011 // update booking record in VBO
1012 if ($dbo->updateObject('#__vikbooking_orders', $booking_record, 'id')) {
1013 // increase counter
1014 $booking_values_counter++;
1015 }
1016
1017 // set a generic CM event type
1018 $ev_type = 'CM';
1019
1020 // build event description
1021 $ev_descr = 'OTA Commissions: ' . $options['compensation'];
1022
1023 // set event extra data
1024 $history_obj->setExtraData([
1025 'cmms' => (float) $options['compensation'],
1026 ]);
1027
1028 // silence the notification center
1029 $history_obj->setSilentNotificationCenter(true);
1030
1031 // store event history log
1032 $history_obj->store($ev_type, $ev_descr);
1033 }
1034
1035 return (bool) $booking_values_counter;
1036 }
1037
1038 /**
1039 * Updates the value for the booking OTA type data payload.
1040 *
1041 * @param array $data OTA type-data payload to set.
1042 * @param ?int $bookingId Optional booking ID to update.
1043 *
1044 * @return bool
1045 *
1046 * @throws Exception
1047 *
1048 * @since 1.18.8 (J) - 1.8.8 (WP)
1049 */
1050 public function updateOTATypeData(array $data, ?int $bookingId = null)
1051 {
1052 $dbo = JFactory::getDbo();
1053
1054 if (!$bookingId) {
1055 // get the internal booking record
1056 $booking = $this->getBooking();
1057 $bookingId = $booking['id'] ?? null;
1058 }
1059
1060 if (empty($bookingId)) {
1061 throw new Exception('Missing booking ID.', 400);
1062 }
1063
1064 // update booking record
1065 $dbo->setQuery(
1066 $dbo->getQuery(true)
1067 ->update($dbo->qn('#__vikbooking_orders'))
1068 ->set($dbo->qn('ota_type_data') . ' = ' . $dbo->q(json_encode($data)))
1069 ->where($dbo->qn('id') . ' = ' . $bookingId)
1070 );
1071 $dbo->execute();
1072
1073 return (bool) $dbo->getAffectedRows();
1074 }
1075
1076 /**
1077 * Updates the registration status for the provided booking.
1078 *
1079 * @param int $status Registration status to set.
1080 * @param ?int $bookingId Optional booking ID to update.
1081 *
1082 * @return bool
1083 *
1084 * @throws Exception
1085 *
1086 * @since 1.18.15 (J) - 1.8.15 (WP)
1087 */
1088 public function updateRegistration(int $status, ?int $bookingId = null)
1089 {
1090 $dbo = JFactory::getDbo();
1091
1092 if (!$bookingId) {
1093 // get the internal booking record
1094 $booking = $this->getBooking();
1095 $bookingId = $booking['id'] ?? null;
1096 }
1097
1098 if (empty($bookingId)) {
1099 throw new Exception('Missing booking ID.', 400);
1100 }
1101
1102 $knownStatuses = [-1, 0, 1, 2];
1103
1104 if (!in_array($status, $knownStatuses, true)) {
1105 throw new InvalidArgumentException('Invalid registration status.', 400);
1106 }
1107
1108 // update booking record
1109 $dbo->setQuery(
1110 $dbo->getQuery(true)
1111 ->update($dbo->qn('#__vikbooking_orders'))
1112 ->set($dbo->qn('checked') . ' = ' . $status)
1113 ->where($dbo->qn('id') . ' = ' . $bookingId)
1114 );
1115 $dbo->execute();
1116
1117 return (bool) $dbo->getAffectedRows();
1118 }
1119
1120 /**
1121 * Updates the booking-level phone number or alias phone number (masked phone).
1122 *
1123 * @param ?string $phone The phone number value to set, even null to unset.
1124 * @param bool $isAlias Whether to update the real or alias phone number.
1125 * @param ?int $bookingId Optional booking ID to update.
1126 *
1127 * @return bool
1128 *
1129 * @throws Exception
1130 *
1131 * @since 1.18.16 (J) - 1.8.16 (WP)
1132 */
1133 public function updatePhoneNumber(?string $phone, bool $isAlias = false, ?int $bookingId = null)
1134 {
1135 $dbo = JFactory::getDbo();
1136
1137 if (!$bookingId) {
1138 // get the internal booking record
1139 $booking = $this->getBooking();
1140 $bookingId = $booking['id'] ?? null;
1141 }
1142
1143 if (empty($bookingId)) {
1144 throw new Exception('Missing booking ID.', 400);
1145 }
1146
1147 $dbo->setQuery(
1148 $dbo->getQuery(true)
1149 ->update('#__vikbooking_orders')
1150 ->set($dbo->qn($isAlias ? 'phone_alias' : 'phone') . ' = ' . ($phone ? $dbo->q($phone) : 'NULL'))
1151 ->where($dbo->qn('id') . ' = ' . (int) $bookingId)
1152 );
1153 $dbo->execute();
1154
1155 return (bool) $dbo->getAffectedRows();
1156 }
1157
1158 /**
1159 * Modifies the requested booking ID according to the provided options.
1160 * This method does not support all rate plan options like for the creation of a
1161 * new booking. This is a method for making quick updates concerning a room switch,
1162 * a change of stay dates, new booking total amount, guests, add extra services etc..
1163 * For modifying a reservation with the same data as for the creation, see update().
1164 *
1165 * @param array $options List of details to perform the modification.
1166 *
1167 * @return bool
1168 *
1169 * @since 1.16.10 (J) - 1.6.10 (WP)
1170 */
1171 public function modify(array $options)
1172 {
1173 $dbo = JFactory::getDbo();
1174
1175 // access the previous booking details
1176 $prev_booking = $this->getBooking();
1177
1178 // access the current rooms booked
1179 $roomBooking = $this->getRoomBooking();
1180
1181 // gather modification options
1182 $booking_id = $options['booking_id'] ?? $prev_booking['id'] ?? 0;
1183
1184 if (!$booking_id) {
1185 $this->setError('Missing booking ID.');
1186 return false;
1187 }
1188
1189 if (!$prev_booking) {
1190 // load current booking record if not injected
1191 $prev_booking = VikBooking::getBookingInfoFromID($booking_id);
1192 if (!$prev_booking) {
1193 $this->setError('Booking not found.');
1194 return false;
1195 }
1196 }
1197
1198 if (!$roomBooking) {
1199 // load current rooms booked
1200 $roomBooking = VikBooking::loadOrdersRoomsData($booking_id);
1201 }
1202
1203 // do not touch this array property because it's used by VCM
1204 $prev_booking['rooms_info'] = $roomBooking;
1205
1206 // list of operations to trigger/perform
1207 $trigger_operations = [];
1208
1209 // list of history description rows
1210 $history_descr_rows = [];
1211
1212 // access availability helper
1213 $av_helper = VikBooking::getAvailabilityInstance(true);
1214
1215 // calculate the new stay dates, if different
1216 $diff_stay_dates = false;
1217 $set_checkin = date('Y-m-d', $prev_booking['checkin']);
1218 $set_checkout = date('Y-m-d', $prev_booking['checkout']);
1219 if (($options['checkin'] ?? null)) {
1220 // date is expected in military format
1221 $diff_stay_dates = $diff_stay_dates || ($options['checkin'] != $set_checkin);
1222 $set_checkin = $options['checkin'];
1223 }
1224 if (($options['checkout'] ?? null)) {
1225 // date is expected in military format
1226 $diff_stay_dates = $diff_stay_dates || ($options['checkout'] != $set_checkout);
1227 $set_checkout = $options['checkout'];
1228 }
1229
1230 // ensure the stay dates are valid
1231 if (JFactory::getDate($set_checkin) >= JFactory::getDate($set_checkout)) {
1232 $this->setError('Invalid stay dates provided.');
1233 return false;
1234 }
1235
1236 // ensure we are not changing dates for a split-stay reservation
1237 if ($diff_stay_dates && !empty($prev_booking['split_stay'])) {
1238 // we receive the stay dates at booking record, so we cannot proceed with the update
1239 $this->setError('Cannot modify the stay dates for a split-stay reservation. Please do it manually.');
1240 return false;
1241 }
1242
1243 // set dates involved
1244 $av_helper->setStayDates($set_checkin, $set_checkout);
1245
1246 // count new nights of stay
1247 $set_nights = $av_helper->countNightsOfStay();
1248
1249 // gather stay timestamps
1250 list($set_checkin_ts, $set_checkout_ts) = $av_helper->getStayDates(true);
1251
1252 // load the current busy record IDs before any modification, if any
1253 $dbo->setQuery(
1254 $dbo->getQuery(true)
1255 ->select('*')
1256 ->from($dbo->qn('#__vikbooking_ordersbusy'))
1257 ->where($dbo->qn('idorder') . ' = ' . (int) $booking_id)
1258 );
1259 $busy_ids = array_column($dbo->loadAssocList(), 'idbusy');
1260
1261 // first off, check if any room switch was requested (recommended one switch at most)
1262 $switching_details = [];
1263 if ($options['switch_rooms'] ?? []) {
1264 // get all room IDs for the switch that were not booked already
1265 $booked_rooms = array_column($roomBooking, 'idroom');
1266 $new_missing_rooms = array_values(array_diff((array) $options['switch_rooms'], $booked_rooms));
1267
1268 // scan all rooms requested for the switch that were not booked already
1269 foreach ($new_missing_rooms as $index => $switch_room_id) {
1270 if (!isset($roomBooking[$index])) {
1271 // adding more rooms is not supported
1272 break;
1273 }
1274 // ensure the room switch is allowed (room should be available on the new dates)
1275 $switched_room_info = VikBooking::getRoomInfo($switch_room_id, ['id', 'name', 'units']);
1276 if (!$switched_room_info) {
1277 $this->setError('The requested room could not be found for the switch.');
1278 return false;
1279 }
1280 if (!VikBooking::roomBookable($switch_room_id, 1, $set_checkin_ts, $set_checkout_ts, $busy_ids)) {
1281 // abort by setting a descriptive error message
1282 $this->setError(sprintf(
1283 'The room %s is not available from %s to %s, and so the room switch cannot be made.',
1284 $switched_room_info['name'] ?? '',
1285 $set_checkin,
1286 $set_checkout
1287 ));
1288 return false;
1289 }
1290 }
1291
1292 // scan again all rooms to be switched once we know they are available
1293 foreach ($new_missing_rooms as $index => $switch_room_id) {
1294 if (!isset($roomBooking[$index])) {
1295 // adding more rooms is not supported
1296 break;
1297 }
1298
1299 // update room-booking record by switching room ID
1300 $q = $dbo->getQuery(true)
1301 ->update($dbo->qn('#__vikbooking_ordersrooms'))
1302 ->set($dbo->qn('idroom') . ' = ' . (int) $switch_room_id)
1303 ->where($dbo->qn('idorder') . ' = ' . (int) $booking_id)
1304 ->where($dbo->qn('idroom') . ' = ' . (int) ($roomBooking[$index]['idroom'] ?? 0));
1305 $dbo->setQuery($q, 0, 1);
1306 $dbo->execute();
1307
1308 if ($busy_ids) {
1309 // update busy records with new stay dates just for the switched room
1310 $q = $dbo->getQuery(true)
1311 ->update($dbo->qn('#__vikbooking_busy'))
1312 ->set($dbo->qn('idroom') . ' = ' . (int) $switch_room_id)
1313 ->set($dbo->qn('checkin') . ' = ' . $dbo->q($set_checkin_ts))
1314 ->set($dbo->qn('checkout') . ' = ' . $dbo->q($set_checkout_ts))
1315 ->set($dbo->qn('realback') . ' = ' . $dbo->q($set_checkout_ts + (VikBooking::getHoursRoomAvail() * 3600)))
1316 ->where($dbo->qn('id') . ' IN (' . implode(', ', array_map('intval', $busy_ids)) . ')')
1317 ->where($dbo->qn('idroom') . ' = ' . (int) ($roomBooking[$index]['idroom'] ?? 0));
1318 $dbo->setQuery($q, 0, 1);
1319 $dbo->execute();
1320
1321 // register room switching details
1322 $switching_details[$index] = $switch_room_id;
1323
1324 // register CM sync operation
1325 $trigger_operations[] = 'vcm_sync';
1326 }
1327
1328 // register history description row
1329 $switched_room_info = VikBooking::getRoomInfo($switch_room_id, ['id', 'name', 'units']);
1330 $prev_room_info = VikBooking::getRoomInfo($roomBooking[$index]['idroom'] ?? 0, ['id', 'name', 'units']);
1331 $history_descr_rows[] = sprintf('%s switched with %s.', $prev_room_info['name'] ?? '', $switched_room_info['name'] ?? '');
1332 }
1333 }
1334
1335 // start query builder for booking record
1336 $bookingQ = $dbo->getQuery(true)
1337 ->update($dbo->qn('#__vikbooking_orders'))
1338 ->where($dbo->qn('id') . ' = ' . (int) $booking_id);
1339
1340 // modify stay dates, if requested
1341 if ($diff_stay_dates) {
1342 // ensure all rooms are bookable on the new stay dates
1343 if ($prev_booking['status'] == 'confirmed') {
1344 foreach ($roomBooking as $kor => $or) {
1345 if ($switching_details[$kor] ?? null) {
1346 // this room index was switched with another room, hence we know it was available
1347 continue;
1348 }
1349 if (!VikBooking::roomBookable($or['idroom'], 1, $set_checkin_ts, $set_checkout_ts, $busy_ids)) {
1350 // abort
1351 $abort_room_info = VikBooking::getRoomInfo($or['idroom'], ['id', 'name', 'units']);
1352 $this->setError(sprintf(
1353 'The room %s is not available from %s to %s, and so the stay dates cannot be modified.',
1354 $abort_room_info['name'] ?? '',
1355 $set_checkin,
1356 $set_checkout
1357 ));
1358 return false;
1359 }
1360 }
1361 }
1362
1363 // set booking values to update
1364 $bookingQ->set($dbo->qn('checkin') . ' = ' . $dbo->q($set_checkin_ts));
1365 $bookingQ->set($dbo->qn('checkout') . ' = ' . $dbo->q($set_checkout_ts));
1366 $bookingQ->set($dbo->qn('days') . ' = ' . $dbo->q($set_nights));
1367
1368 // update busy records, if any (reservation status could be confirmed)
1369 if ($busy_ids) {
1370 // update busy records with new stay dates for all rooms
1371 $dbo->setQuery(
1372 $dbo->getQuery(true)
1373 ->update($dbo->qn('#__vikbooking_busy'))
1374 ->set($dbo->qn('checkin') . ' = ' . $dbo->q($set_checkin_ts))
1375 ->set($dbo->qn('checkout') . ' = ' . $dbo->q($set_checkout_ts))
1376 ->set($dbo->qn('realback') . ' = ' . $dbo->q($set_checkout_ts + (VikBooking::getHoursRoomAvail() * 3600)))
1377 ->where($dbo->qn('id') . ' IN (' . implode(', ', array_map('intval', $busy_ids)) . ')')
1378 );
1379 $dbo->execute();
1380
1381 // register CM sync operation
1382 $trigger_operations[] = 'vcm_sync';
1383 }
1384
1385 // register operation to trigger the shared calendars
1386 $trigger_operations[] = 'shared_calendars';
1387 }
1388
1389 // update number of guests, if requested
1390 if (($options['guests']['adults'] ?? null) || ($options['guests']['children'] ?? null)) {
1391 // update the requested number of guests ONLY on the first room booked
1392 $q = $dbo->getQuery(true)
1393 ->update($dbo->qn('#__vikbooking_ordersrooms'))
1394 ->set($dbo->qn('adults') . ' = ' . (int) ($options['guests']['adults'] ?? $roomBooking[0]['adults']))
1395 ->set($dbo->qn('children') . ' = ' . (int) ($options['guests']['children'] ?? $roomBooking[0]['children']))
1396 ->where($dbo->qn('idorder') . ' = ' . (int) $booking_id);
1397 $dbo->setQuery($q, 0, 1);
1398 $dbo->execute();
1399
1400 // register history description row
1401 $history_descr_rows[] = sprintf(
1402 'New adults %d, new children %d.',
1403 (int) ($options['guests']['adults'] ?? $roomBooking[0]['adults']),
1404 (int) ($options['guests']['children'] ?? $roomBooking[0]['children'])
1405 );
1406 }
1407
1408 // check if extra services should be added and calculate the booking cost difference
1409 $new_extras_cost = 0;
1410 if (is_array(($options['add_extra_services'] ?? null))) {
1411 $current_extras = !empty($roomBooking[0]['extracosts']) ? json_decode($roomBooking[0]['extracosts'], true) : [];
1412 $current_extras = is_array($current_extras) ? $current_extras : [];
1413 $new_extras = [];
1414 foreach ($options['add_extra_services'] as $extras) {
1415 if (!is_array($extras) || (!isset($extras['name']) && !isset($extras['cost']))) {
1416 // invalid extra service structure
1417 continue;
1418 }
1419
1420 // build new extra service
1421 $new_extra = [
1422 'name' => (string) ($extras['name'] ?? 'Custom Extra'),
1423 'cost' => (float) ($extras['cost'] ?? 0),
1424 'idtax' => null,
1425 ];
1426
1427 // push custom extra service
1428 $current_extras[] = $new_extra;
1429
1430 // push the custom extra service in the new list
1431 $new_extras[] = $new_extra;
1432 }
1433
1434 if ($new_extras) {
1435 // update the extra services ONLY on the first room booked
1436 $q = $dbo->getQuery(true)
1437 ->update($dbo->qn('#__vikbooking_ordersrooms'))
1438 ->set($dbo->qn('extracosts') . ' = ' . $dbo->q(json_encode($current_extras)))
1439 ->where($dbo->qn('idorder') . ' = ' . (int) $booking_id);
1440 $dbo->setQuery($q, 0, 1);
1441 $dbo->execute();
1442
1443 // register history description row
1444 $history_descr_rows[] = sprintf(
1445 'New extras: %s.',
1446 implode(', ', array_column($new_extras, 'name'))
1447 );
1448
1449 // check if we need to increase the booking total amount
1450 $new_extras_cost = array_sum(array_column($new_extras, 'cost'));
1451
1452 if ($new_extras_cost > 0 && (float) ($options['cost_difference'] ?? 0) < $new_extras_cost) {
1453 // increase the "cost difference" due to the newly added extra services
1454 $options['cost_difference'] = ($options['cost_difference'] ?? 0) + $new_extras_cost;
1455 }
1456 }
1457 }
1458
1459 // check if the booking total amount should change
1460 if ($options['cost_difference'] ?? null) {
1461 // this difference should be summed to (or deducted from) the current booking total value
1462 $bookingQ->set($dbo->qn('total') . ' = ' . ($prev_booking['total'] + (float) $options['cost_difference']));
1463
1464 // register history description row
1465 $history_descr_rows[] = sprintf(
1466 'Booking total cost difference calculated: %d.',
1467 (float) $options['cost_difference']
1468 );
1469
1470 // calculate the cost difference just for the rooms
1471 $rooms_cost_difference = (float) $options['cost_difference'] - $new_extras_cost;
1472
1473 if ($rooms_cost_difference) {
1474 // this value should be summed to (or deducted from) the current room rate to have a proper calculation
1475 $new_room_cost = null;
1476 $room_cost_prop = null;
1477 if (!empty($roomBooking[0]['cust_cost'])) {
1478 $new_room_cost = $roomBooking[0]['cust_cost'] + $rooms_cost_difference;
1479 $room_cost_prop = 'cust_cost';
1480 } elseif (!empty($roomBooking[0]['room_cost'])) {
1481 $new_room_cost = $roomBooking[0]['room_cost'] + $rooms_cost_difference;
1482 $room_cost_prop = 'room_cost';
1483 }
1484
1485 if ($room_cost_prop) {
1486 // we can update the room cost for the difference calculated ONLY on the first room booked
1487 // if no room cost was found, maybe because of a tariff, we would keep just the total changed
1488 $q = $dbo->getQuery(true)
1489 ->update($dbo->qn('#__vikbooking_ordersrooms'))
1490 ->set($dbo->qn($room_cost_prop) . ' = ' . $new_room_cost)
1491 ->where($dbo->qn('idorder') . ' = ' . (int) $booking_id);
1492 $dbo->setQuery($q, 0, 1);
1493 $dbo->execute();
1494 }
1495 }
1496 }
1497
1498 if ($options['extra_notes'] ?? '') {
1499 // update administrator notes
1500 $bookingQ->set($dbo->qn('adminnotes') . ' = ' . $dbo->q(trim($prev_booking['adminnotes'] . "\n" . $options['extra_notes'])));
1501 }
1502
1503 if (($options['custmail'] ?? '') || ($options['customer_email'] ?? '')) {
1504 // update guest email address at booking level
1505 $set_cust_mail = $options['custmail'] ?? $options['customer_email'] ?? '';
1506 if (preg_match("/^[^@]+@[^@]+\.[^@]+$/", $set_cust_mail)) {
1507 // email pattern is safe
1508 $bookingQ->set($dbo->qn('custmail') . ' = ' . $dbo->q(trim($set_cust_mail)));
1509 }
1510 }
1511
1512 // finally, update the booking record
1513 try {
1514 // make sure something to update was set by using the apposite getter magic method
1515 if ($bookingQ->set) {
1516 // some booking record values should be updated
1517 $dbo->setQuery($bookingQ);
1518 $dbo->execute();
1519 }
1520 } catch (Throwable $e) {
1521 $this->setError($e->getMessage());
1522 return false;
1523 }
1524
1525 // update booking history
1526 $history_obj = VikBooking::getBookingHistoryInstance($booking_id);
1527
1528 $now_user = JFactory::getUser();
1529 $caller_id = $now_user->name ? "({$now_user->name})" : '';
1530 if ($this->getCaller()) {
1531 $caller_id = '(' . $this->getCaller() . ')';
1532 if ($this->getHistoryData()) {
1533 $history_obj->setExtraData($this->getHistoryData());
1534 }
1535 }
1536
1537 // update Booking History
1538 $history_obj->store('MB', $caller_id . ($history_descr_rows ? "\n" . implode("\n", $history_descr_rows) : ''));
1539
1540 // check for the operations to perform
1541 if (in_array('shared_calendars', $trigger_operations)) {
1542 // unset any previously booked room due to calendar sharing
1543 VikBooking::cleanSharedCalendarsBusy($booking_id);
1544 // check if some of the rooms booked have shared calendars
1545 VikBooking::updateSharedCalendars($booking_id);
1546 }
1547
1548 if (in_array('vcm_sync', $trigger_operations)) {
1549 // invoke Channel Manager
1550 $vcm_autosync = VikBooking::vcmAutoUpdate();
1551 if ($vcm_autosync > 0) {
1552 $vcm_obj = VikBooking::getVcmInvoker();
1553 $vcm_obj->setOids([$booking_id])->setSyncType('modify')->setOriginalBooking($prev_booking);
1554 $sync_result = $vcm_obj->doSync();
1555 if ($sync_result === false) {
1556 // set error message
1557 $vcm_err = $vcm_obj->getError();
1558 $this->setError(JText::translate('VBCHANNELMANAGERRESULTKO') . (!empty($vcm_err) ? ' - ' . $vcm_err : ''));
1559 }
1560 } elseif (is_file(VCM_SITE_PATH . DIRECTORY_SEPARATOR . 'helpers' . DIRECTORY_SEPARATOR . 'synch.vikbooking.php')) {
1561 // set the necessary action to invoke VCM manually
1562 $this->setChannelManagerAction(
1563 JText::translate('VBCHANNELMANAGERINVOKEASK') . ' ' .
1564 '<form action="index.php?option=com_vikbooking" method="post">' .
1565 '<input type="hidden" name="option" value="com_vikbooking"/>' .
1566 '<input type="hidden" name="task" value="invoke_vcm"/>' .
1567 '<input type="hidden" name="stype" value="modify"/>' .
1568 '<input type="hidden" name="cid[]" value="' . $booking_id . '"/>' .
1569 '<input type="hidden" name="origb" value="' . urlencode(json_encode($prev_booking)) . '"/>' .
1570 '<button type="submit" class="btn btn-primary">' . JText::translate('VBCHANNELMANAGERSENDRQ') . '</button>' .
1571 '</form>'
1572 );
1573 }
1574 }
1575
1576 if (($options['ota_reporting'] ?? null) && $diff_stay_dates) {
1577 // perform the OTA reporting action, if allowed
1578 if (class_exists('VCMOtaReporting') && VCMOtaReporting::getInstance($ord)->stayChangeAllowed()) {
1579 // check if an OTA reporting action is needed
1580 $ota_stay_change_data = [];
1581 foreach ($roomBooking as $kor => $or) {
1582 // set room data for stay change
1583 $ota_stay_change_room = [
1584 'idroom' => $or['idroom'],
1585 'checkin' => $set_checkin,
1586 'checkout' => $set_checkout,
1587 ];
1588 if (isset($or['modified_price'])) {
1589 $ota_stay_change_room['price'] = $or['modified_price'];
1590 }
1591 // push room data for stay change
1592 $ota_stay_change_data[] = $ota_stay_change_room;
1593 }
1594
1595 // notify the OTA through Vik Channel Manager
1596 $ota_reporting = VCMOtaReporting::getInstance();
1597 $ota_result = $ota_reporting->notifyStayChange($ota_stay_change_data);
1598 if (!$ota_result) {
1599 // register error message
1600 $this->setError($ota_reporting->getError());
1601 }
1602 }
1603 }
1604
1605 return true;
1606 }
1607
1608 /**
1609 * Deletes the requested booking ID and related records.
1610 *
1611 * @param array $options List of details to perform the cancellation.
1612 * @param bool $reCreating True if the booking is being re-created (updated).
1613 *
1614 * @return bool
1615 *
1616 * @since 1.16.10 (J) - 1.6.10 (WP)
1617 * @since 1.18.11 (J) - 1.8.11 (WP) added argument $reCreating.
1618 */
1619 public function delete(array $options, bool $reCreating = false)
1620 {
1621 $dbo = JFactory::getDbo();
1622
1623 $booking_id = $options['booking_id'] ?? 0;
1624 $canc_reason = $options['cancellation_reason'] ?? '';
1625 $purge_remove = $options['purge_remove'] ?? false;
1626
1627 if ($reCreating && $this->prevBookingRegistry && $this->prevBookingRegistry->getID() == $booking_id) {
1628 $booking = $this->prevBookingRegistry->getData();
1629 } else {
1630 $booking = VikBooking::getBookingInfoFromID($booking_id);
1631 }
1632
1633 if (!$booking) {
1634 $this->setError('Booking not found.');
1635 return false;
1636 }
1637
1638 if (!$reCreating && $booking['status'] === 'cancelled' && !$purge_remove) {
1639 $this->setError(sprintf('Booking ID %d is already cancelled.', $booking['id']));
1640 return false;
1641 }
1642
1643 if (!$reCreating && class_exists('VCMFeesCancellation')) {
1644 // let VCM detect if there are any constraints for the cancellation
1645 $canc_denied = VCMFeesCancellation::getInstance($booking, $anew = true)->isBookingConstrained();
1646 if ($canc_denied) {
1647 // set error message
1648 $canc_deny_error = VCMFeesCancellation::getInstance()->getError();
1649 $this->setError($canc_deny_error ?: 'Booking cannot be cancelled due to OTA contraints.');
1650 return false;
1651 }
1652 }
1653
1654 // access the current user
1655 $now_user = JFactory::getUser();
1656
1657 // whether OTAs should be notified
1658 $notify_otas = false;
1659
1660 if ($booking['status'] != 'cancelled') {
1661 if (!$reCreating) {
1662 // update status to cancelled
1663 $q = $dbo->getQuery(true)
1664 ->update($dbo->qn('#__vikbooking_orders'))
1665 ->set($dbo->qn('status') . ' = ' . $dbo->q('cancelled'))
1666 ->where($dbo->qn('id') . ' = ' . (int) $booking['id']);
1667 if (!empty($canc_reason)) {
1668 $set_canc_reason = (!empty($booking['adminnotes']) ? $booking['adminnotes'] . "\n" : '') . $canc_reason;
1669 $q->set($dbo->qn('adminnotes') . ' = ' . $dbo->q($set_canc_reason));
1670 }
1671 $dbo->setQuery($q);
1672 $dbo->execute();
1673 }
1674
1675 // delete temporarily locked records, if any
1676 $dbo->setQuery(
1677 $dbo->getQuery(true)
1678 ->delete($dbo->qn('#__vikbooking_tmplock'))
1679 ->where($dbo->qn('idorder') . ' = ' . (int) $booking['id'])
1680 );
1681 $dbo->execute();
1682
1683 if (!$reCreating && $booking['status'] == 'confirmed') {
1684 // turn flag on
1685 $notify_otas = true;
1686 }
1687
1688 if (!$reCreating) {
1689 // access history object
1690 $history_obj = VikBooking::getBookingHistoryInstance($booking['id']);
1691
1692 $caller_id = $now_user->name ? "({$now_user->name})" : '';
1693 if ($this->getCaller()) {
1694 $caller_id = '(' . $this->getCaller() . ')';
1695 if ($this->getHistoryData()) {
1696 $history_obj->setExtraData($this->getHistoryData());
1697 }
1698 }
1699
1700 // update Booking History
1701 $history_obj->store('CB', $caller_id);
1702 }
1703 }
1704
1705 /**
1706 * In case of pending bookings being cancelled, schedule the release through VCM.
1707 *
1708 * @since 1.18.8 (J) - 1.8.8 (WP)
1709 */
1710 if ($booking['status'] == 'standby' && method_exists('VCMRequestAvailability', 'setForRelease')) {
1711 // let the CM schedule the release of the involved and unconfirmed booking IDs, if needed
1712 VCMRequestAvailability::getInstance()->setForRelease([$booking['id']]);
1713 }
1714
1715 // always attempt to free records up
1716 $dbo->setQuery(
1717 $dbo->getQuery(true)
1718 ->select('*')
1719 ->from($dbo->qn('#__vikbooking_ordersbusy'))
1720 ->where($dbo->qn('idorder') . ' = ' . (int) $booking['id'])
1721 );
1722 foreach ($dbo->loadAssocList() as $ob) {
1723 // delete busy record
1724 $dbo->setQuery(
1725 $dbo->getQuery(true)
1726 ->delete($dbo->qn('#__vikbooking_busy'))
1727 ->where($dbo->qn('id') . ' = ' . (int) $ob['idbusy'])
1728 );
1729 $dbo->execute();
1730 }
1731
1732 // delete booking-busy-record relations
1733 $dbo->setQuery(
1734 $dbo->getQuery(true)
1735 ->delete($dbo->qn('#__vikbooking_ordersbusy'))
1736 ->where($dbo->qn('idorder') . ' = ' . (int) $booking['id'])
1737 );
1738 $dbo->execute();
1739
1740 // check for purge removal
1741 if (!$reCreating && $booking['status'] === 'cancelled' && $purge_remove) {
1742 // delete booking-customer relation
1743 $dbo->setQuery(
1744 $dbo->getQuery(true)
1745 ->delete($dbo->qn('#__vikbooking_customers_orders'))
1746 ->where($dbo->qn('idorder') . ' = ' . (int) $booking['id'])
1747 );
1748 $dbo->execute();
1749
1750 // delete booking-room relations
1751 $dbo->setQuery(
1752 $dbo->getQuery(true)
1753 ->delete($dbo->qn('#__vikbooking_ordersrooms'))
1754 ->where($dbo->qn('idorder') . ' = ' . (int) $booking['id'])
1755 );
1756 $dbo->execute();
1757
1758 // delete booking-history relations
1759 $dbo->setQuery(
1760 $dbo->getQuery(true)
1761 ->delete($dbo->qn('#__vikbooking_orderhistory'))
1762 ->where($dbo->qn('idorder') . ' = ' . (int) $booking['id'])
1763 );
1764 $dbo->execute();
1765
1766 // delete the booking record
1767 $dbo->setQuery(
1768 $dbo->getQuery(true)
1769 ->delete($dbo->qn('#__vikbooking_orders'))
1770 ->where($dbo->qn('id') . ' = ' . (int) $booking['id'])
1771 );
1772 $dbo->execute();
1773
1774 // in case of split stay booking, remove the transient
1775 if ($booking['split_stay']) {
1776 VBOFactory::getConfig()->remove('split_stay_' . $booking['id']);
1777 }
1778 }
1779
1780 if ($reCreating === true) {
1781 // when updating a booking record, get rid of all rooms previously assigned
1782
1783 // delete booking-room relations
1784 $dbo->setQuery(
1785 $dbo->getQuery(true)
1786 ->delete($dbo->qn('#__vikbooking_ordersrooms'))
1787 ->where($dbo->qn('idorder') . ' = ' . (int) $booking['id'])
1788 );
1789 $dbo->execute();
1790
1791 // in case of split stay booking, remove the transient
1792 if ($booking['split_stay']) {
1793 VBOFactory::getConfig()->remove('split_stay_' . $booking['id']);
1794 }
1795 }
1796
1797 if (!$reCreating && $notify_otas) {
1798 $vcm_autosync = VikBooking::vcmAutoUpdate();
1799 if ($vcm_autosync > 0) {
1800 $vcm_obj = VikBooking::getVcmInvoker();
1801 $vcm_obj->setOids([$booking['id']])->setSyncType('cancel');
1802 $sync_result = $vcm_obj->doSync();
1803 if ($sync_result === false) {
1804 // set error message
1805 $vcm_err = $vcm_obj->getError();
1806 $this->setError(JText::translate('VBCHANNELMANAGERRESULTKO') . (!empty($vcm_err) ? ' - ' . $vcm_err : ''));
1807 }
1808 }
1809 }
1810
1811 return true;
1812 }
1813
1814 /**
1815 * Sets a booking ID to confirmed according to the provided options.
1816 *
1817 * @param array $options List of details to perform the update.
1818 *
1819 * @return bool
1820 *
1821 * @since 1.17.3 (J) - 1.7.3 (WP)
1822 */
1823 public function setConfirmed(array $options)
1824 {
1825 $dbo = JFactory::getDbo();
1826
1827 // access the current booking details, if any
1828 $booking = $this->getBooking();
1829
1830 // access the current rooms booked, if any
1831 $roomBooking = $this->getRoomBooking();
1832
1833 // gather modification options
1834 $booking_id = $options['booking_id'] ?? $booking['id'] ?? 0;
1835
1836 if (!$booking_id) {
1837 $this->setError('Missing booking ID.');
1838 return false;
1839 }
1840
1841 if (!$booking) {
1842 // load current booking record if not injected
1843 $booking = VikBooking::getBookingInfoFromID($booking_id);
1844 if (!$booking) {
1845 $this->setError('Booking not found.');
1846 return false;
1847 }
1848 }
1849
1850 if (!$roomBooking) {
1851 // load current rooms booked
1852 $roomBooking = VikBooking::loadOrdersRoomsData($booking_id);
1853 }
1854
1855 // make sure the booking status is not already confirmed
1856 if (!strcasecmp($booking['status'], 'confirmed')) {
1857 $this->setError('Booking is already confirmed.');
1858 return false;
1859 }
1860
1861 // memorize the original booking status for VCM in case of OTA booking
1862 $original_book_status = null;
1863 if (!empty($booking['idorderota']) && !empty($booking['channel'])) {
1864 $original_book_status = $booking['status'];
1865 }
1866
1867 // availability helper
1868 $av_helper = VikBooking::getAvailabilityInstance(true);
1869
1870 // room stay dates in case of split stay
1871 $room_stay_dates = [];
1872 if ($booking['split_stay']) {
1873 $room_stay_dates = VBOFactory::getConfig()->getArray('split_stay_' . $booking['id'], []);
1874 }
1875
1876 // make sure all rooms are available for confirmation
1877 $turnover_secs = VikBooking::getHoursRoomAvail() * 3600;
1878 $realback = $turnover_secs + $booking['checkout'];
1879 $allbook = true;
1880 $notavail = [];
1881
1882 /**
1883 * We need to calculate a minus operator for each room that was booked more than once.
1884 * In case we are confirming a booking for more than one unit of the same room, we need to
1885 * make sure the calculation is made properly, as only one unit of that room could be free.
1886 */
1887 $units_minus_oper = [];
1888 foreach ($roomBooking as $ind => $or) {
1889 if (!isset($units_minus_oper[$or['idroom']])) {
1890 $units_minus_oper[$or['idroom']] = -1;
1891 }
1892 // increase counter
1893 $units_minus_oper[$or['idroom']]++;
1894 if (!empty($room_stay_dates)) {
1895 // split stay rooms never have the same stay dates, but they should also be different rooms
1896 $units_minus_oper[$or['idroom']] = 0;
1897 }
1898 }
1899
1900 // list of room-units pool
1901 $roomUnits = [];
1902
1903 // check availability for each room involved
1904 foreach ($roomBooking as $ind => $or) {
1905 // determine proper values for this room
1906 $room_stay_checkin = $booking['checkin'];
1907 $room_stay_checkout = $booking['checkout'];
1908 $room_stay_nights = $booking['days'];
1909 if ($booking['split_stay'] && $room_stay_dates && isset($room_stay_dates[$ind]) && $room_stay_dates[$ind]['idroom'] == $or['idroom']) {
1910 $room_stay_checkin = $room_stay_dates[$ind]['checkin_ts'] ?: $room_stay_dates[$ind]['checkin'];
1911 $room_stay_checkout = $room_stay_dates[$ind]['checkout_ts'] ?: $room_stay_dates[$ind]['checkout'];
1912 $room_stay_nights = $av_helper->countNightsOfStay($room_stay_checkin, $room_stay_checkout);
1913 // inject nights calculated for this room
1914 $room_stay_dates[$ind]['nights'] = $room_stay_nights;
1915 }
1916
1917 // get room record
1918 $room_record = VikBooking::getRoomInfo($or['idroom']);
1919
1920 // set room (total) units
1921 $roomUnits[$or['idroom']] = (int) ($room_record['units'] ?? 1);
1922
1923 // check if the room is available
1924 if (!VikBooking::roomBookable($or['idroom'], (($room_record['units'] ?? 0) - $units_minus_oper[$or['idroom']]), $room_stay_checkin, $room_stay_checkout)) {
1925 $allbook = false;
1926 $notavail[] = $room_record['name'] ?? '?';
1927 }
1928 }
1929
1930 // ensure all rooms involved were available or forced to be
1931 if (!$allbook && !($options['force_availability'] ?? false)) {
1932 $this->setError(sprintf('Some rooms are no longer available: %s', implode(', ', $notavail)));
1933 return false;
1934 }
1935
1936 // occupy the involved rooms on the db
1937 foreach ($roomBooking as $ind => $or) {
1938 // determine proper values for this room
1939 $room_stay_checkin = $booking['checkin'];
1940 $room_stay_checkout = $booking['checkout'];
1941 $room_stay_realback = $realback;
1942 if ($booking['split_stay'] && $room_stay_dates && isset($room_stay_dates[$ind]) && $room_stay_dates[$ind]['idroom'] == $or['idroom']) {
1943 $room_stay_checkin = $room_stay_dates[$ind]['checkin_ts'] ?: $room_stay_dates[$ind]['checkin'];
1944 $room_stay_checkout = $room_stay_dates[$ind]['checkout_ts'] ?: $room_stay_dates[$ind]['checkout'];
1945 $room_stay_realback = $turnover_secs + $room_stay_checkout;
1946 }
1947
1948 // determine the room units to occupy (to support closure re-confirmations)
1949 $bookUnitsCount = (int) (intval($booking['closure'] ?? 0) ? ($roomUnits[$or['idroom']] ?? 1) : 1);
1950
1951 for ($bi = 1; $bi <= $bookUnitsCount; $bi++) {
1952 // build busy record
1953 $busy_record = new stdClass;
1954 $busy_record->idroom = (int) $or['idroom'];
1955 $busy_record->checkin = (int) $room_stay_checkin;
1956 $busy_record->checkout = (int) $room_stay_checkout;
1957 $busy_record->realback = (int) $room_stay_realback;
1958
1959 // store busy record and obtain the newly created ID
1960 $dbo->insertObject('#__vikbooking_busy', $busy_record, 'id');
1961 $lid = $busy_record->id ?? 0;
1962
1963 // build busy relation record
1964 $obusy_record = new stdClass;
1965 $obusy_record->idorder = (int) $booking['id'];
1966 $obusy_record->idbusy = (int) $lid;
1967
1968 // store busy relation record
1969 $dbo->insertObject('#__vikbooking_ordersbusy', $obusy_record, 'id');
1970 }
1971 }
1972
1973 // delete temporarily locked records, if any
1974 $dbo->setQuery(
1975 $dbo->getQuery(true)
1976 ->delete($dbo->qn('#__vikbooking_tmplock'))
1977 ->where($dbo->qn('idorder') . ' = ' . (int) $booking['id'])
1978 );
1979 $dbo->execute();
1980
1981 // update booking status (and notes, if any)
1982 $q = $dbo->getQuery(true)
1983 ->update($dbo->qn('#__vikbooking_orders'))
1984 ->set($dbo->qn('status') . ' = ' . $dbo->q('confirmed'))
1985 ->where($dbo->qn('id') . ' = ' . (int) $booking['id']);
1986 if ($options['extra_notes'] ?? '') {
1987 // update administrator notes
1988 $q->set($dbo->qn('adminnotes') . ' = ' . $dbo->q(trim($booking['adminnotes'] . "\n" . $options['extra_notes'])));
1989 }
1990 $dbo->setQuery($q);
1991 $dbo->execute();
1992
1993 if (!empty($booking['idquote'])) {
1994 // let the quote model handle the release of other locked room records (OTAs included), if any
1995 VBOMvcModel::getInstance('quote')->releaseUnconfirmedSolutions((int) $booking['idquote'], (int) $booking['id']);
1996 }
1997
1998 // set booking confirmation number
1999 $confirmnumber = VikBooking::generateConfirmNumber($booking['id'], true);
2000
2001 // assign room specific unit(s)
2002 $set_room_indexes = VikBooking::autoRoomUnit();
2003 $room_indexes_usemap = [];
2004
2005 foreach ($roomBooking as $kor => $or) {
2006 // determine proper values for this room
2007 $room_stay_checkin = $booking['checkin'];
2008 $room_stay_checkout = $booking['checkout'];
2009 $room_stay_nights = $booking['days'];
2010 if ($booking['split_stay'] && $room_stay_dates && isset($room_stay_dates[$kor]) && $room_stay_dates[$kor]['idroom'] == $or['idroom']) {
2011 $room_stay_checkin = $room_stay_dates[$kor]['checkin_ts'] ?: $room_stay_dates[$kor]['checkin'];
2012 $room_stay_checkout = $room_stay_dates[$kor]['checkout_ts'] ?: $room_stay_dates[$kor]['checkout'];
2013 $room_stay_nights = $room_stay_dates[$kor]['nights'];
2014 }
2015
2016 // assign room specific unit
2017 if ($set_room_indexes === true) {
2018 $room_indexes = VikBooking::getRoomUnitNumsAvailable($booking, $or['idroom']);
2019 $use_ind_key = 0;
2020 if ($room_indexes) {
2021 if (!isset($room_indexes_usemap[$or['idroom']])) {
2022 $room_indexes_usemap[$or['idroom']] = $use_ind_key;
2023 } else {
2024 $use_ind_key = $room_indexes_usemap[$or['idroom']];
2025 }
2026
2027 // update room-reservation record by assigning the room index (unit)
2028 $dbo->setQuery(
2029 $dbo->getQuery(true)
2030 ->update($dbo->qn('#__vikbooking_ordersrooms'))
2031 ->set($dbo->qn('roomindex') . ' = ' . (int) $room_indexes[$use_ind_key])
2032 ->where($dbo->qn('id') . ' = ' . (int) $or['id'])
2033 );
2034 $dbo->execute();
2035
2036 // increase index counter
2037 $room_indexes_usemap[$or['idroom']]++;
2038 }
2039 }
2040 }
2041
2042 // check if some of the rooms booked have shared calendars
2043 VikBooking::updateSharedCalendars($booking['id'], array_column($roomBooking, 'idroom'), $booking['checkin'], $booking['checkout']);
2044
2045 // update booking history
2046 $history_obj = VikBooking::getBookingHistoryInstance($booking['id']);
2047
2048 $now_user = JFactory::getUser();
2049 $caller_id = $now_user->name ? "({$now_user->name})" : '';
2050 if ($this->getCaller()) {
2051 $caller_id = '(' . $this->getCaller() . ')';
2052 if ($this->getHistoryData()) {
2053 $history_obj->setExtraData($this->getHistoryData());
2054 }
2055 }
2056
2057 // update Booking History
2058 $history_obj->store('TC', $caller_id);
2059
2060 // Invoke Channel Manager
2061 $vcm_autosync = VikBooking::vcmAutoUpdate();
2062 if ($vcm_autosync > 0) {
2063 $vcm_obj = VikBooking::getVcmInvoker();
2064 $vcm_obj->setOids([$booking['id']])->setSyncType('new')->setOriginalStatuses([$original_book_status]);
2065 $sync_result = $vcm_obj->doSync();
2066 if ($sync_result === false) {
2067 // set error message
2068 $vcm_err = $vcm_obj->getError();
2069 $this->setError(JText::translate('VBCHANNELMANAGERRESULTKO') . (!empty($vcm_err) ? ' - ' . $vcm_err : ''));
2070 }
2071 } elseif (is_file(VCM_SITE_PATH . DIRECTORY_SEPARATOR . 'helpers' . DIRECTORY_SEPARATOR . 'synch.vikbooking.php')) {
2072 // set the necessary action to invoke VCM
2073 $vcm_sync_url = 'index.php?option=com_vikbooking&task=invoke_vcm&stype=new&cid[]=' . $booking['id'] . '&returl=' . urlencode('index.php?option=com_vikbooking&task=editorder&cid[]=' . $booking['id']);
2074
2075 $this->setChannelManagerAction(JText::translate('VBCHANNELMANAGERINVOKEASK') . ' <button type="button" class="btn btn-primary" onclick="document.location.href=\'' . $vcm_sync_url . '\';">' . JText::translate('VBCHANNELMANAGERSENDRQ') . '</button>');
2076 }
2077
2078 // check if the guest should be notified via email
2079 if ($options['notify'] ?? null) {
2080 // send email notification to guest
2081 VikBooking::sendBookingEmail($booking['id'], ['guest']);
2082
2083 // SMS skipping the administrator
2084 VikBooking::sendBookingSMS($booking['id'], ['admin']);
2085 }
2086
2087 return true;
2088 }
2089
2090 /**
2091 * Tells if the booking record set can be modified and/or cancelled.
2092 *
2093 * @return array
2094 *
2095 * @since 1.16.10 (J) - 1.6.10 (WP)
2096 */
2097 public function getAlterationDetails()
2098 {
2099 $dbo = JFactory::getDbo();
2100
2101 $booking = $this->getBooking();
2102 $roomBooking = $this->getRoomBooking();
2103
2104 if (!$booking || !$roomBooking) {
2105 $this->setError('Missing booking or room booking record details.');
2106 return [];
2107 }
2108
2109 // gather room booking tariffs
2110 $tars = [];
2111 foreach ($roomBooking as $kor => $or) {
2112 $num = $kor + 1;
2113 if (!empty($order['pkg']) || (!empty($or['cust_cost']) && $or['cust_cost'] > 0.00)) {
2114 // package or custom cost set from the back-end
2115 continue;
2116 }
2117
2118 // get room tariff details
2119 $dbo->setQuery(
2120 $dbo->getQuery(true)
2121 ->select($dbo->qn('t') . '.*')
2122 ->select([
2123 $dbo->qn('p.name'),
2124 $dbo->qn('p.free_cancellation'),
2125 $dbo->qn('p.canc_deadline'),
2126 $dbo->qn('p.canc_policy'),
2127 ])
2128 ->from($dbo->qn('#__vikbooking_dispcost', 't'))
2129 ->leftJoin($dbo->qn('#__vikbooking_prices', 'p') . ' ON ' . $dbo->qn('t.idprice') . ' = ' . $dbo->qn('p.id'))
2130 ->where($dbo->qn('t.id') . ' = ' . (int) ($or['idtar'] ?? 0))
2131 );
2132 $tar = $dbo->loadAssoc();
2133
2134 if ($tar) {
2135 // push room booking tariff
2136 $tars[$num] = $tar;
2137 }
2138 }
2139
2140 // count days to arrival
2141 $days_to_arrival = 0;
2142 $now_info = getdate();
2143 $checkin_info = getdate($booking['checkin'] ?? 0);
2144 if ($now_info[0] < $checkin_info[0]) {
2145 while ($now_info[0] < $checkin_info[0]) {
2146 if (!($now_info['mday'] != $checkin_info['mday'] || $now_info['mon'] != $checkin_info['mon'] || $now_info['year'] != $checkin_info['year'])) {
2147 break;
2148 }
2149 $days_to_arrival++;
2150 $now_info = getdate(mktime(0, 0, 0, $now_info['mon'], ($now_info['mday'] + 1), $now_info['year']));
2151 }
2152 }
2153
2154 // check if the rate plan(s) are refundable
2155 $is_refundable = 0;
2156 $daysadv_refund_arr = [];
2157 $daysadv_refund = 0;
2158 $canc_policy = '';
2159 foreach ($tars as $num => $tar) {
2160 if (!$tar['free_cancellation']) {
2161 // if at least one rate plan is non-refundable, the whole reservation cannot be cancelled
2162 $is_refundable = 0;
2163 $daysadv_refund_arr = [];
2164 break;
2165 }
2166 $is_refundable = 1;
2167 $daysadv_refund_arr[] = $tar['canc_deadline'];
2168 }
2169
2170 // get the rate plan with the lowest cancellation deadline
2171 $daysadv_refund = $daysadv_refund_arr ? min($daysadv_refund_arr) : $daysadv_refund;
2172 if ($daysadv_refund > 0) {
2173 foreach ($tars as $num => $tar) {
2174 if ($tar['free_cancellation'] && $tar['canc_deadline'] == $daysadv_refund) {
2175 // get the cancellation policy from the first rate plan with free cancellation and same cancellation deadline
2176 $canc_policy = $tar['canc_policy'];
2177 break;
2178 }
2179 }
2180 }
2181
2182 // access global settings to determine the alterations available
2183 $resmodcanc = VikBooking::getReservationModCanc();
2184 $resmodcanc = !$days_to_arrival ? 0 : $resmodcanc;
2185 $resmodcancmin = VikBooking::getReservationModCancMin();
2186
2187 // build alteration deadline date
2188 $checkin_dt = JFactory::getDate(date('Y-m-d', ($booking['checkin'] ?? 0)));
2189 $checkin_dt->modify("-{$resmodcancmin} days");
2190 $alteration_deadline = $checkin_dt->format('Y-m-d');
2191
2192 return [
2193 'refundable' => ($resmodcanc > 1 && $resmodcanc != 2 && $is_refundable > 0 && $daysadv_refund <= $days_to_arrival && $days_to_arrival >= $resmodcancmin),
2194 'modifiable' => ($resmodcanc > 1 && $resmodcanc != 3 && $days_to_arrival >= $resmodcancmin),
2195 'alteration_disabled' => $resmodcanc === 0,
2196 'request_alteration' => $resmodcanc === 1,
2197 'cancellation_policy' => $canc_policy ?: null,
2198 'alteration_deadline' => $alteration_deadline,
2199 ];
2200 }
2201
2202 /**
2203 * Tells whether a given booking ID can be moved to new stay dates. Ignores rooms individual stay
2204 * dates or split stay reservations. Should be called when changing the dates for a whole booking.
2205 *
2206 * @param int $booking_id The booking ID to modify.
2207 * @param int|string $new_checkin The new check-in date (timestamp or date string with no time).
2208 * @param int|string $new_checkout The new check-out date (timestamp or date string with no time).
2209 *
2210 * @return bool True if all booking rooms are available on the new dates.
2211 *
2212 * @throws Exception
2213 *
2214 * @since 1.18.2 (J) - 1.8.2 (WP)
2215 */
2216 public function bookingModifiable(int $booking_id, $new_checkin, $new_checkout)
2217 {
2218 // attempt to access previous booking registry to reduce queries
2219 if ($this->prevBookingRegistry && $this->prevBookingRegistry->getID() == $booking_id) {
2220 $booking_rooms = $this->prevBookingRegistry->getRooms();
2221 } else {
2222 // load all booking rooms
2223 $booking_rooms = VikBooking::loadOrdersRoomsData($booking_id);
2224 }
2225
2226 if (!$booking_rooms) {
2227 throw new Exception('Could not find any rooms booked within the reservation.', 500);
2228 }
2229
2230 if (empty($new_checkin) || empty($new_checkout)) {
2231 throw new Exception('Missing stay dates.', 500);
2232 }
2233
2234 // load the booking occupied record IDs, if any
2235 $busy_ids = VikBooking::loadBookingBusyIds($booking_id);
2236
2237 // load check-in and check-out times
2238 list($checkin_h, $checkin_m, $checkout_h, $checkout_m) = $this->loadCheckinOutTimes();
2239
2240 // get the new stay timestamps
2241 $new_checkin = is_numeric($new_checkin) ? date('Y-m-d', $new_checkin) : $new_checkin;
2242 $new_checkout = is_numeric($new_checkout) ? date('Y-m-d', $new_checkout) : $new_checkout;
2243 $from_ts = VikBooking::getDateTimestamp($new_checkin, $checkin_h, $checkin_m);
2244 $to_ts = VikBooking::getDateTimestamp($new_checkout, $checkout_h, $checkout_m);
2245
2246 if ($to_ts <= $from_ts) {
2247 throw new Exception('Invalid stay dates provided.', 500);
2248 }
2249
2250 // count room equal units
2251 $rooms_units_counter = [];
2252 foreach ($booking_rooms as $booking_room) {
2253 $rooms_units_counter[$booking_room['idroom']] = ($rooms_counter[$booking_room['idroom']] ?? -1) + 1;
2254 }
2255
2256 // check if the booking modification involves other rooms
2257 $roomsPool = $this->getRoomsPool();
2258 $oldRoomIds = array_map('intval', array_column($booking_rooms, 'idroom'));
2259 $newRoomIds = array_values(array_unique(array_map('intval', array_column($roomsPool, 'id'))));
2260 $diffIds = array_diff($newRoomIds, $oldRoomIds);
2261 foreach ($diffIds as $newId) {
2262 $unitsRequested = 0;
2263 $totUnits = 0;
2264 foreach ($roomsPool as $roomData) {
2265 if ($newId == $roomData['id']) {
2266 $unitsRequested++;
2267 $totUnits = (int) ($roomData['units'] ?? 1);
2268 }
2269 }
2270 if (!$unitsRequested) {
2271 continue;
2272 }
2273
2274 // update units counter map for multiple units requested
2275 $rooms_units_counter[$newId] = $unitsRequested - 1;
2276
2277 // push additional room for availability validation
2278 $booking_rooms[] = [
2279 'idroom' => $newId,
2280 'tot_units' => $totUnits,
2281 ];
2282 }
2283
2284 // check the availability for each room booked
2285 $rooms_parsed = [];
2286 foreach ($booking_rooms as $booking_room) {
2287 if (in_array($booking_room['idroom'], $rooms_parsed)) {
2288 continue;
2289 }
2290
2291 // count effective units to check
2292 $effective_units = $booking_room['tot_units'] - $rooms_units_counter[$booking_room['idroom']];
2293
2294 // check if the room is available on the new dates
2295 if (!VikBooking::roomBookable($booking_room['idroom'], $effective_units, $from_ts, $to_ts, $busy_ids)) {
2296 // the room is occupied
2297 return false;
2298 }
2299
2300 // push room just checked
2301 $rooms_parsed[] = $booking_room['idroom'];
2302 }
2303
2304 return true;
2305 }
2306
2307 /**
2308 * Attempts to invoke the payment processor assigned to the current booking.
2309 *
2310 * @param array $card Optional credit card details to bind.
2311 *
2312 * @return object The payment processor dispatcher instance.
2313 *
2314 * @throws Exception
2315 *
2316 * @since 1.16.10 (J) - 1.6.10 (WP)
2317 * @since 1.17.6 (J) - 1.7.6 (WP) added "tn_metadata" details.
2318 */
2319 public function getPaymentProcessor(array $card = [])
2320 {
2321 $booking = $this->getProperties();
2322 $processor = null;
2323 $payment = [];
2324
2325 if (!$booking) {
2326 throw new Exception('Missing booking details', 500);
2327 }
2328
2329 if (!empty($booking['idpayment'])) {
2330 $payment = VikBooking::getPayment($booking['idpayment']);
2331 }
2332
2333 if (!$payment) {
2334 throw new Exception('Missing payment method details', 500);
2335 }
2336
2337 // set payment details internally
2338 $this->set('_payment_info', $payment);
2339
2340 if ($card) {
2341 // inject CC details for the payment processor
2342 $booking['card'] = $card;
2343 }
2344
2345 // get the booking customer record, if any
2346 $customer = $this->getCustomer();
2347 if (!$customer) {
2348 $customer = VikBooking::getCPinInstance()->getCustomerFromBooking($booking['id']);
2349 }
2350
2351 // build and inject transaction metadata
2352 $booking['tn_metadata'] = [
2353 'booking_id' => $booking['id'],
2354 'source' => (($booking['channel'] ?? '') ?: 'Website'),
2355 'ota_booking_id' => (($booking['idorderota'] ?? '') ?: ''),
2356 'guest_name' => implode(' ', array_filter([($customer['first_name'] ?? ''), ($customer['last_name'] ?? '')])),
2357 'guest_email' => $customer['email'] ?? null,
2358 'guest_phone' => $customer['phone'] ?? null,
2359 'guest_country' => $customer['country'] ?? null,
2360 ];
2361
2362 /**
2363 * Trigger event to allow third-party plugins to manipulate the transaction data.
2364 *
2365 * @since 1.18.5 (J) - 1.8.5 (WP)
2366 */
2367 VBOFactory::getPlatform()->getDispatcher()->trigger('onInitPaymentTransaction', [&$booking, &$payment['params'], $customer]);
2368
2369 if (VBOPlatformDetection::isWordPress()) {
2370 /**
2371 * @wponly The payment gateway is loaded
2372 * through the apposite dispatcher.
2373 */
2374 JLoader::import('adapter.payment.dispatcher');
2375 $processor = JPaymentDispatcher::getInstance('vikbooking', $payment['file'], $booking, $payment['params']);
2376 } elseif (VBOPlatformDetection::isJoomla()) {
2377 /**
2378 * @joomlaonly The Payment Factory library will invoke the gateway.
2379 */
2380 require_once VBO_ADMIN_PATH . DIRECTORY_SEPARATOR . 'payments' . DIRECTORY_SEPARATOR . 'libraries' . DIRECTORY_SEPARATOR . 'factory.php';
2381 $processor = VBOPaymentFactory::getPaymentInstance($payment['file'], $booking, $payment['params']);
2382 }
2383
2384 if (!$processor) {
2385 throw new Exception('Could not invoke the payment processor', 500);
2386 }
2387
2388 // return the valid payment processor instance
2389 return $processor;
2390 }
2391
2392 /**
2393 * Gets the reservation's payment method name.
2394 *
2395 * @return string
2396 *
2397 * @since 1.16.10 (J) - 1.6.10 (WP)
2398 */
2399 public function getPaymentName()
2400 {
2401 // access the reserved property
2402 $payment = (array) $this->get('_payment_info', []);
2403
2404 if (!$payment) {
2405 return '';
2406 }
2407
2408 return $payment['name'] ?? '';
2409 }
2410
2411 /**
2412 * Attempts to get the most recent transaction data for an off-session capturing.
2413 *
2414 * @param string $tn_driver Optional payment processor driver name.
2415 *
2416 * @return object[] Eligible transaction data list or empty array.
2417 *
2418 * @since 1.18.0 (J) - 1.8.0 (WP)
2419 */
2420 public function getOffSessionTransactionData(string $tn_driver = '')
2421 {
2422 // transaction data validation callback
2423 $tn_data_callback = function($data) use ($tn_driver) {
2424 return (is_object($data) && isset($data->driver) && (!$tn_driver || basename($data->driver, '.php') == basename($tn_driver, '.php')) && ($data->future_usage ?? null));
2425 };
2426
2427 // get previous transactions (in date ascending order)
2428 $prev_tn_data = (array) VikBooking::getBookingHistoryInstance($this->get('id', 0))->getEventsWithData(['P0', 'PN'], $tn_data_callback);
2429
2430 if (!$prev_tn_data) {
2431 return [];
2432 }
2433
2434 // return the eligible transaction data list in reverse order
2435 return array_reverse(array_values(array_filter(array_map(function($data) {
2436 if (is_array($data)) {
2437 // cast to object
2438 $data = (object) $data;
2439 }
2440 return is_object($data) ? $data : null;
2441 }, $prev_tn_data))));
2442 }
2443
2444 /**
2445 * Attempts to get the credit card value pairs from the current booking.
2446 *
2447 * @return array Associative list of CC value-pairs, if any.
2448 *
2449 * @since 1.16.10 (J) - 1.6.10 (WP)
2450 */
2451 public function getCardValuePairs()
2452 {
2453 $booking_info = $this->getProperties();
2454
2455 if (empty($booking_info['paymentlog']) && empty($booking_info['idorderota'])) {
2456 // do not proceed when no CC data is available, or cannot be obtained via API
2457 return [];
2458 }
2459
2460 // build complete credit card payload, if available
2461 $cc_payload_str = '';
2462
2463 // extract CC data from payment logs by ensuring they're not null
2464 $booking_info['paymentlog'] = (string) $booking_info['paymentlog'];
2465 if (stripos($booking_info['paymentlog'], 'card number') !== false && strpos($booking_info['paymentlog'], '*') !== false) {
2466 // matched a log for an OTA CC
2467 $cc_payload_str = $booking_info['paymentlog'];
2468 } elseif (preg_match("/(([\d\*]{4,4}\s*){4,4})|(([\d\*]{4,6}\s*){3,3})/", $booking_info['paymentlog'])) {
2469 // matched a credit card
2470 $cc_payload_str = $booking_info['paymentlog'];
2471 }
2472
2473 // check if this is an OTA reservation with remotely decoded CC details required
2474 $remote_cc_data = [];
2475 if (!empty($booking_info['idorderota']) && !empty($booking_info['channel'])) {
2476 // channel source
2477 $channel_source = (string)$booking_info['channel'];
2478 if (strpos($booking_info['channel'], '_') !== false) {
2479 $channelparts = explode('_', $booking_info['channel']);
2480 $channel_source = $channelparts[0];
2481 }
2482
2483 // only updated versions of VCM will support remote CC decoding for OTA reservations
2484 if (class_exists('VCMOtaBooking')) {
2485 // invoke the OTA Booking helper class from VCM
2486 $cc_helper = VCMOtaBooking::getInstance([
2487 'channel_source' => $channel_source,
2488 'ota_id' => $booking_info['idorderota'],
2489 'booking' => $booking_info,
2490 ], $anew = true);
2491
2492 if (method_exists($cc_helper, 'decodeCreditCardDetails')) {
2493 $remote_cc_data = $cc_helper->decodeCreditCardDetails();
2494 // make sure the response was valid
2495 if (!$remote_cc_data || !empty($remote_cc_data['error'])) {
2496 // we ignore the error by simply resetting the array
2497 $remote_cc_data = [];
2498 }
2499 }
2500 }
2501 }
2502
2503 // check if we have already a full VCC
2504 if (($remote_cc_data['card_number'] ?? '') && strlen(preg_replace('/[^0-9]/', '', (string) $remote_cc_data['card_number'])) >= 15) {
2505 // do not merge any local data and return the full VCC details
2506 return $remote_cc_data;
2507 }
2508
2509 // merge remotely decoded CC details with parsed payment log (if any)
2510 return array_merge($remote_cc_data, $this->parseCreditCardValuePairs($cc_payload_str, $remote_cc_data));
2511 }
2512
2513 /**
2514 * Given a raw string of credit card key-value pairs from payments log,
2515 * parse the corresponding keys and values into an associative array.
2516 * In case of conflicting keys with the remotely decoded CC details,
2517 * attempts to replace the masked numbers with asterisks.
2518 *
2519 * @param string $cc_payload the raw CC details from payment logs.
2520 * @param array $remote_cc_data associative array of decoded CC data.
2521 *
2522 * @return array associative or empty array.
2523 *
2524 * @since 1.16.10 (J) - 1.6.10 (WP) moved from widget Virtual Terminal.
2525 */
2526 protected function parseCreditCardValuePairs($cc_payload, array $remote_cc_data = [])
2527 {
2528 $cc_value_pairs = [];
2529
2530 if (empty($cc_payload)) {
2531 return $cc_value_pairs;
2532 }
2533
2534 $cc_lines = preg_split("/(\r\n|\n|\r)/", $cc_payload);
2535
2536 foreach ($cc_lines as $cc_line) {
2537 if (strpos($cc_line, ':') === false) {
2538 continue;
2539 }
2540
2541 $cc_line_parts = explode(':', $cc_line);
2542
2543 if (empty($cc_line_parts[0]) || !strlen(trim($cc_line_parts[1]))) {
2544 continue;
2545 }
2546
2547 $key = str_replace(' ', '_', strtolower($cc_line_parts[0]));
2548 $value = trim($cc_line_parts[1]);
2549
2550 if (isset($cc_value_pairs[$key])) {
2551 /**
2552 * Do not overwrite existing keys because this probably means that the
2553 * credit card was updated by an OTA like Booking.com, hence the payment
2554 * logs string in VBO may contain the information of two different cards.
2555 * New credit card details are always pre-pended by VCM in the payment logs.
2556 */
2557 continue;
2558 }
2559
2560 if (!empty($remote_cc_data[$key]) && is_string($remote_cc_data[$key]) && strpos($value, '*') !== false) {
2561 // replace masked numbers with remote content
2562 $value = $this->replaceMaskedNumbers($value, $remote_cc_data[$key]);
2563 }
2564
2565 $cc_value_pairs[$key] = $value;
2566 }
2567
2568 return $cc_value_pairs;
2569 }
2570
2571 /**
2572 * Given a local and a remote credit card number string with
2573 * masked symbols, replaces the values in the corresponding
2574 * positions with the unmasked numbers.
2575 *
2576 * @param string $local current string with masked values.
2577 * @param string $remote remote string with unmasked values.
2578 *
2579 * @return string the local string with unmasked values.
2580 *
2581 * @since 1.16.10 (J) - 1.6.10 (WP) moved from widget Virtual Terminal.
2582 */
2583 protected function replaceMaskedNumbers($local, $remote)
2584 {
2585 // split anything but numbers
2586 $numbers = preg_split("/([^0-9]+)/", trim($remote));
2587
2588 if ($numbers) {
2589 // filter empty values
2590 $numbers = array_filter($numbers);
2591 }
2592
2593 if (!$numbers) {
2594 // unable to proceed
2595 return $local;
2596 }
2597
2598 // split anything but stars (asterisks)
2599 $stars = preg_split("/([^\*]+)/", trim($local));
2600
2601 if ($stars) {
2602 // filter empty values
2603 $stars = array_filter($stars);
2604 }
2605
2606 if (!$stars) {
2607 // unable to proceed
2608 return $local;
2609 }
2610
2611 // replace masked symbols with numbers at their first occurrence
2612 foreach ($numbers as $k => $unmasked) {
2613 if (!isset($stars[$k])) {
2614 continue;
2615 }
2616
2617 $masked_pos = strpos($local, $stars[$k]);
2618
2619 if ($masked_pos === false) {
2620 continue;
2621 }
2622
2623 $local = substr_replace($local, $unmasked, $masked_pos, strlen($stars[$k]));
2624 }
2625
2626 // return the string with possibly unmasked values
2627 return $local;
2628 }
2629
2630 /**
2631 * Tells whether the booking can be created. By default this
2632 * is only allowed from the administrator section of the site.
2633 *
2634 * @return bool
2635 */
2636 protected function canCreate()
2637 {
2638 return $this->get('_isAdministrator') || JFactory::getApplication()->isClient('administrator');
2639 }
2640
2641 /**
2642 * Gets and sets the tariff ID if a rate plan was set.
2643 *
2644 * @param ?array $roomData Optional room data to evaluate.
2645 *
2646 * @return int The tariff ID found, or 0.
2647 *
2648 * @since 1.18.8 (J) - 1.8.8 (WP) added argument $roomData for multi-room booking context.
2649 */
2650 protected function loadTariffID(?array $roomData = null)
2651 {
2652 $dbo = JFactory::getDbo();
2653
2654 $id_tariff = 0;
2655
2656 // access current room data
2657 $room = $roomData ?: $this->getRoom();
2658
2659 // current nights of stay
2660 $daysdiff = (int) (($room['nights'] ?? 0) ?: $this->get('nights', 1));
2661
2662 // make sure we have a rate plan ID with a cost set
2663 if (!empty($room['id']) && !empty($room['id_price']) && !empty($room['room_cost']) && !boolval($this->get('set_closed', 0)) && !$this->get('split_stay', [])) {
2664 // load tariff for given room, rate plan and nights of stay
2665 $dbo->setQuery(
2666 $dbo->getQuery(true)
2667 ->select($dbo->qn('id'))
2668 ->from($dbo->qn('#__vikbooking_dispcost'))
2669 ->where($dbo->qn('idroom') . ' = ' . (int) $room['id'])
2670 ->where($dbo->qn('days') . ' = ' . $daysdiff)
2671 ->where($dbo->qn('idprice') . ' = ' . (int) $room['id_price'])
2672 );
2673
2674 $id_tariff = (int) $dbo->loadResult();
2675 }
2676
2677 // set current tariff ID
2678 $this->set('id_tariff', $id_tariff);
2679
2680 // return the current tariff ID
2681 return $id_tariff;
2682 }
2683
2684 /**
2685 * Applies the turnover time to the checkout timestamp and sets its value.
2686 *
2687 * @return int the turnover seconds applied.
2688 */
2689 protected function applyTurnover()
2690 {
2691 $turnover_secs = 0;
2692 $checkout = $this->get('checkout', 0);
2693
2694 if ($checkout) {
2695 // turnover time
2696 $turnover_secs = VikBooking::getHoursRoomAvail() * 3600;
2697
2698 $this->set('checkout_real', ($checkout + $turnover_secs));
2699 }
2700
2701 $this->set('turnover_secs', $turnover_secs);
2702
2703 return $turnover_secs;
2704 }
2705
2706 /**
2707 * Returns an associative list of all rooms data.
2708 *
2709 * @return array
2710 *
2711 * @since 1.18.8 (J) - 1.8.8 (WP)
2712 */
2713 protected function loadAllRoomsData()
2714 {
2715 if (!$this->allRooms) {
2716 // load once and cache internally
2717 $this->allRooms = VikBooking::getAvailabilityInstance(true)->loadRooms();
2718 }
2719
2720 return $this->allRooms;
2721 }
2722
2723 /**
2724 * Returns the details of a specific room ID.
2725 *
2726 * @param ?int $rid Optional room ID to fetch.
2727 *
2728 * @return array The record found or empty array.
2729 */
2730 protected function getRoomDetails(?int $rid = null)
2731 {
2732 $all_rooms = $this->loadAllRoomsData();
2733
2734 if (!$rid) {
2735 $inj_room = $this->getRoom();
2736 $rid = $inj_room['id'] ?? 0;
2737 }
2738
2739 if ($rid && isset($all_rooms[$rid])) {
2740 return $all_rooms[$rid];
2741 }
2742
2743 return [];
2744 }
2745
2746 /**
2747 * Gets the list of rooms involved in the reservation in case of
2748 * closures or if multiple rooms were set for booking.
2749 *
2750 * @return array The list of rooms involved.
2751 */
2752 protected function getRoomsPool()
2753 {
2754 // preload all rooms
2755 $av_helper = VikBooking::getAvailabilityInstance(true);
2756 $all_rooms = $this->loadAllRoomsData();
2757
2758 // access the first room set for booking
2759 $room = $this->getRoom();
2760 if (empty($room['id'])) {
2761 return [];
2762 }
2763 $room = $this->getRoomDetails($room['id']);
2764
2765 // gather values
2766 $set_close_others = (array) $this->get('close_others', []);
2767 $split_stay_data = $this->get('split_stay', []);
2768 $set_closed = (int) $this->get('set_closed');
2769 $turnover_secs = $this->get('turnover_secs', 0);
2770 $hcheckin = $this->get('checkin_h', 12);
2771 $mcheckin = $this->get('checkin_m', 0);
2772 $hcheckout = $this->get('checkout_h', 10);
2773 $mcheckout = $this->get('checkout_m', 0);
2774
2775 // build containers
2776 $rooms_pool = [];
2777 $closeothers = [];
2778
2779 if ($set_close_others && $set_closed) {
2780 // prepend current room for closing
2781 array_unshift($set_close_others, $room['id']);
2782 }
2783 $set_close_others = array_unique($set_close_others);
2784
2785 foreach ($set_close_others as $closeid) {
2786 if (empty($closeid)) {
2787 continue;
2788 }
2789 if ((int) $closeid === -1) {
2790 // close all rooms
2791 $closeothers = [];
2792 foreach ($all_rooms as $cr) {
2793 array_push($closeothers, $cr);
2794 }
2795 break;
2796 }
2797 foreach ($all_rooms as $cr) {
2798 if ((int) $cr['id'] == (int) $closeid) {
2799 // push the main room or one of the other rooms requested for closure
2800 array_push($closeothers, $cr);
2801 break;
2802 }
2803 }
2804 }
2805
2806 if (!$closeothers || !$set_closed) {
2807 // check if we are in a multi-room booking context
2808 $multi_rooms = $this->getRooms();
2809 if (count($multi_rooms) > 1) {
2810 // multiple rooms set for booking
2811 $rooms_pool = array_values(array_filter(array_map(function($roomData) {
2812 return $this->getRoomDetails($roomData['id'] ?? -1);
2813 }, $multi_rooms)));
2814 } else {
2815 // single room involved
2816 $rooms_pool = [$room];
2817 }
2818 } else {
2819 // set all rooms involved
2820 $rooms_pool = $closeothers;
2821 }
2822
2823 // check split stay rooms booking
2824 if (!empty($split_stay_data)) {
2825 // reset pool and set it with the split stay rooms
2826 $rooms_pool = [];
2827 foreach ($split_stay_data as $sps_k => $split_stay) {
2828 if (!isset($all_rooms[$split_stay['idroom']])) {
2829 continue;
2830 }
2831 // calculate and set the exact check-in and check-out timestamps for this split-room
2832 $split_stay['checkin_ts'] = VikBooking::getDateTimestamp($split_stay['checkin'], $hcheckin, $mcheckin);
2833 $split_stay['checkout_ts'] = VikBooking::getDateTimestamp($split_stay['checkout'], $hcheckout, $mcheckout);
2834 $split_stay['realback_ts'] = $turnover_secs + $split_stay['checkout_ts'];
2835 $split_stay['nights'] = $av_helper->countNightsOfStay($split_stay['checkin_ts'], $split_stay['checkout_ts']);
2836 $split_stay_data[$sps_k] = $split_stay;
2837 // push room data to pool after storing additional information
2838 $room_data = $all_rooms[$split_stay['idroom']];
2839 $room_data['checkin_ts'] = $split_stay['checkin_ts'];
2840 $room_data['checkout_ts'] = $split_stay['checkout_ts'];
2841 $rooms_pool[] = $room_data;
2842 }
2843 if (!$rooms_pool) {
2844 $this->setError('No valid rooms for the split stay booking');
2845 return [];
2846 }
2847 // update split stay data manipulated
2848 $this->set('split_stay', $split_stay_data);
2849 }
2850
2851 return $rooms_pool;
2852 }
2853
2854 /**
2855 * Checks if the rooms are available on the requested dates.
2856 *
2857 * @param array $rooms_pool Pool of rooms involved in the reservation.
2858 *
2859 * @return bool
2860 *
2861 * @since 1.18.8 (J) - 1.8.8 (WP) added argument $rooms_pool.
2862 */
2863 protected function checkRoomsAvailability(array $rooms_pool)
2864 {
2865 if (empty($rooms_pool[0]['id'])) {
2866 // invalid argument
2867 return false;
2868 }
2869
2870 // gather variables
2871 $split_stay_data = $this->get('split_stay', []);
2872 $set_closed = $this->get('set_closed', 0);
2873 $num_rooms = $this->isMultiRoom() ? count($rooms_pool) : $this->get('num_rooms', 1);
2874 $default_checkin = $this->get('checkin', 0);
2875 $default_checkout = $this->get('checkout', 0);
2876 $check_locked = (bool) $this->get('check_locked', 0);
2877
2878 // determine availability
2879 $rooms_available = true;
2880
2881 // check if we are dealing with split-stay data or regular booking values
2882 if (empty($split_stay_data)) {
2883 // make sure the rooms are available
2884 if ($this->isMultiRoom() && !$set_closed) {
2885 // multi-room booking context with support for stay dates at room-level
2886 $roomBookings = $this->getRooms();
2887 $roomStayData = [];
2888 $roomUnitsUse = [];
2889 $roomNamesMap = [];
2890 foreach ($rooms_pool as $index => $room) {
2891 $roomUnitsUse[$room['id']] = ($roomUnitsUse[$room['id']] ?? 0) + 1;
2892 $roomNamesMap[$room['id']] = $room['name'] ?? $room['id'];
2893 $roomStayData[] = [
2894 'idroom' => $room['id'],
2895 'units' => ($room['units'] ?? 1),
2896 'checkin' => $roomBookings[$index]['checkin'] ?? $default_checkin,
2897 'checkout' => $roomBookings[$index]['checkout'] ?? $default_checkout,
2898 ];
2899 }
2900 foreach ($roomStayData as $roomStay) {
2901 // check the remaining availability for the number of room units booked
2902 $unitsBooked = $roomUnitsUse[$roomStay['idroom']];
2903 $check_units = $roomStay['units'] - $unitsBooked + 1;
2904 if ($check_locked) {
2905 // check if the room is available and not temporarily locked
2906 $rooms_available = VikBooking::roomNotLocked($roomStay['idroom'], $check_units, $roomStay['checkin'], $roomStay['checkout'], true);
2907 } else {
2908 // check if the room is available
2909 $rooms_available = VikBooking::roomBookable($roomStay['idroom'], $check_units, $roomStay['checkin'], $roomStay['checkout']);
2910 }
2911 if (!$rooms_available) {
2912 // set an error and abort
2913 $this->setError(sprintf(
2914 'Room "%s" is not available from %s to %s. Looking for %d free unit(s) over a total of %d.',
2915 $roomNamesMap[$roomStay['idroom']],
2916 date('Y-m-d H:i', $roomStay['checkin']),
2917 date('Y-m-d H:i', $roomStay['checkout']),
2918 $unitsBooked,
2919 $roomStay['units']
2920 ));
2921 break;
2922 }
2923 }
2924 } else {
2925 // single-room booking context
2926 $check_units = $rooms_pool[0]['units'] ?? 1;
2927 if ($num_rooms > 1 && $num_rooms <= $check_units && !$set_closed) {
2928 // only when non closing the room we check the availability for the units requested for booking
2929 $check_units = $check_units - $num_rooms + 1;
2930 }
2931 if ($check_locked) {
2932 // check if the room is available and not temporarily locked
2933 $rooms_available = VikBooking::roomNotLocked($rooms_pool[0]['id'], $check_units, $default_checkin, $default_checkout, true);
2934 } else {
2935 // check if the room is available
2936 $rooms_available = VikBooking::roomBookable($rooms_pool[0]['id'], $check_units, $default_checkin, $default_checkout);
2937 }
2938 }
2939 } else {
2940 $all_rooms = $this->loadAllRoomsData();
2941 // make sure the rooms for the split stay are available
2942 foreach ($split_stay_data as $split_stay) {
2943 if (!isset($all_rooms[$split_stay['idroom']])) {
2944 $rooms_available = false;
2945 break;
2946 }
2947 $rooms_available = $rooms_available && VikBooking::roomBookable($split_stay['idroom'], $all_rooms[$split_stay['idroom']]['units'], $split_stay['checkin_ts'], $split_stay['checkout_ts']);
2948 }
2949 }
2950
2951 return $rooms_available;
2952 }
2953
2954 /**
2955 * In case the reservation is forced or is a closure, we detect the
2956 * forced reason to eventually attach it to the booking history.
2957 *
2958 * @param bool $rooms_available Whether the rooms are available.
2959 *
2960 * @return void
2961 */
2962 protected function detectForcedReason($rooms_available = true)
2963 {
2964 $split_stay_data = $this->get('split_stay', []);
2965 $force_booking = $this->get('force_booking', 0);
2966 $set_closed = $this->get('set_closed', 0);
2967
2968 $forced_reason = $this->get('forced_reason', '');
2969
2970 if (empty($split_stay_data)) {
2971 // eventually build string for the description of the history event
2972 if (($force_booking || $set_closed) && !$rooms_available) {
2973 $forced_reason = JText::translate('VBO_FORCED_BOOKDATES');
2974 }
2975 } else {
2976 $all_rooms = $this->loadAllRoomsData();
2977 // set "split stay" as the description of the history event
2978 $forced_reason = JText::translate('VBO_SPLIT_STAY') . "\n";
2979 foreach ($split_stay_data as $sps_k => $split_stay) {
2980 // describe the split stay for each room
2981 if (!isset($all_rooms[$split_stay['idroom']])) {
2982 continue;
2983 }
2984 $room_stay_nights = $split_stay['nights'];
2985 $forced_reason .= $all_rooms[$split_stay['idroom']]['name'] . ': ' . $room_stay_nights . ' ' . ($room_stay_nights > 1 ? JText::translate('VBDAYS') : JText::translate('VBDAY')) . ', ';
2986 $forced_reason .= $split_stay['checkin'] . ' - ' . $split_stay['checkout'] . "\n";
2987 }
2988 $forced_reason = rtrim($forced_reason, "\n");
2989 }
2990
2991 $this->set('forced_reason', $forced_reason);
2992 }
2993
2994 /**
2995 * Stores the customer information to a new or existing record.
2996 * In case of success, the customer ID property is updated.
2997 * The customer shall be stored before the reservation records.
2998 *
2999 * @return bool
3000 */
3001 protected function storeCustomer()
3002 {
3003 $dbo = JFactory::getDbo();
3004
3005 $inj_customer = $this->getCustomer();
3006 $first_name = !empty($inj_customer['first_name']) ? $inj_customer['first_name'] : '';
3007 $last_name = !empty($inj_customer['last_name']) ? $inj_customer['last_name'] : '';
3008 $custdata = !empty($inj_customer['data']) ? $inj_customer['data'] : '';
3009 $email = !empty($inj_customer['email']) ? $inj_customer['email'] : '';
3010 $country = !empty($inj_customer['country']) ? $inj_customer['country'] : '';
3011 $phone = !empty($inj_customer['phone']) ? $inj_customer['phone'] : '';
3012 $gender = !empty($inj_customer['gender']) ? $inj_customer['gender'] : '';
3013 $address = !empty($inj_customer['address']) ? $inj_customer['address'] : '';
3014 $city = !empty($inj_customer['city']) ? $inj_customer['city'] : '';
3015 $zip = !empty($inj_customer['zip']) ? $inj_customer['zip'] : '';
3016
3017 // custom fields
3018 $q = "SELECT * FROM `#__vikbooking_custfields` ORDER BY `ordering` ASC;";
3019 $dbo->setQuery($q);
3020 $all_cfields = $dbo->loadAssocList();
3021
3022 $customer_cfields = [];
3023 $customer_extrainfo = [];
3024 $custdata_parts = explode("\n", $custdata);
3025 foreach ($custdata_parts as $cdataline) {
3026 if (!strlen(trim($cdataline))) {
3027 continue;
3028 }
3029 $cdata_parts = explode(':', $cdataline);
3030 if (count($cdata_parts) < 2 || !strlen(trim($cdata_parts[0])) || !strlen(trim($cdata_parts[1]))) {
3031 continue;
3032 }
3033 foreach ($all_cfields as $cf) {
3034 $needle = JText::translate($cf['name']);
3035 if (!empty($needle) && strpos($cdata_parts[0], $needle) !== false && !array_key_exists($cf['id'], $customer_cfields) && $cf['type'] != 'country') {
3036 $user_input_val = trim($cdata_parts[1]);
3037 $customer_cfields[$cf['id']] = $user_input_val;
3038 if (!empty($cf['flag'])) {
3039 $customer_extrainfo[$cf['flag']] = $user_input_val;
3040 } elseif ($cf['type'] == 'state') {
3041 $customer_extrainfo['state'] = $user_input_val;
3042 }
3043 break;
3044 }
3045 }
3046 }
3047
3048 if (!empty($gender) && in_array(strtoupper((string) $gender), ['M', 'F'])) {
3049 // inject the customer gender value
3050 $customer_extrainfo['gender'] = strtoupper($gender);
3051 }
3052
3053 if (!empty($address)) {
3054 // inject extra info
3055 $customer_extrainfo['address'] = strlen((string) $address) > 200 ? substr($address, 0, 200) : $address;
3056 }
3057
3058 if (!empty($city)) {
3059 // inject extra info
3060 $customer_extrainfo['city'] = strlen((string) $city) > 50 ? substr($city, 0, 50) : $city;
3061 }
3062
3063 if (!empty($zip)) {
3064 // inject extra info
3065 $customer_extrainfo['zip'] = strlen((string) $zip) > 10 ? substr($zip, 0, 10) : $zip;
3066 }
3067
3068 $cpin = VikBooking::getCPinInstance();
3069 $cpin->is_admin = true;
3070 $cpin->setCustomerExtraInfo($customer_extrainfo);
3071 $cpin->saveCustomerDetails($first_name, $last_name, $email, $phone, $country, $customer_cfields);
3072
3073 $customer_id = $cpin->getNewCustomerId();
3074 if (!$customer_id) {
3075 return false;
3076 }
3077
3078 $inj_customer['id'] = $customer_id;
3079 $this->setCustomer($inj_customer);
3080
3081 return true;
3082 }
3083
3084 /**
3085 * Returns the calculated total booking amount and total taxes.
3086 * Sets the necessary properties with the calculated amounts.
3087 *
3088 * @return array list of booking total amount, taxes and fees.
3089 */
3090 protected function calculateTotal()
3091 {
3092 $dbo = JFactory::getDbo();
3093
3094 // the values to calculate
3095 $set_total = 0;
3096 $set_taxes = 0;
3097 $set_city_tax = 0;
3098 $set_fees = 0;
3099
3100 // access all rooms booking data
3101 $roomsData = $this->getRooms();
3102
3103 if (!$roomsData) {
3104 return [$set_total, $set_taxes];
3105 }
3106
3107 // get booking values
3108 $set_closed = $this->get('set_closed', 0);
3109 $daysdiff = (int) $this->get('nights', 1);
3110 $num_rooms = (int) $this->get('num_rooms', 1);
3111 $split_stay_data = $this->get('split_stay', []);
3112
3113 // iterate all booking rooms data
3114 foreach ($roomsData as $index => $roomData) {
3115 // gather booking room values
3116 $totalpnight = ($roomData['total_or_pnight'] ?? '') ?: 'total';
3117 $cust_cost = (float) ($roomData['cust_cost'] ?? 0);
3118 $room_cost = (float) ($roomData['room_cost'] ?? 0);
3119 $id_price = (int) ($roomData['id_price'] ?? 0);
3120 $id_tax = (int) ($roomData['id_tax'] ?? 0);
3121 $guess_tax = $roomData['guess_tax'] ?? false;
3122
3123 // access room details
3124 $roomDetails = $this->getRoomDetails($roomData['id'] ?? 0);
3125
3126 // check for stay dates at room level
3127 $roomNightsStay = ($roomData['nights'] ?? 0) ?: $daysdiff;
3128
3129 // calculate room totals
3130 $roomTotalValue = 0;
3131 $roomTotalTax = 0;
3132 $roomTotalCityTax = 0;
3133 $roomTotalFees = 0;
3134
3135 if ($cust_cost > 0 && !$set_closed) {
3136 // custom cost can be per night
3137 if ($totalpnight == 'pnight') {
3138 $cust_cost = $cust_cost * $roomNightsStay;
3139 }
3140 $roomTotalValue = $cust_cost;
3141
3142 if (!$id_tax && $guess_tax) {
3143 // try to guess the tax rate
3144 $dbo->setQuery(
3145 $dbo->getQuery(true)
3146 ->select($dbo->qn('id'))
3147 ->from($dbo->qn('#__vikbooking_iva'))
3148 ->order($dbo->qn('aliq') . ' ASC'),
3149 0, 1);
3150 $guessed_id_tax = $dbo->loadResult();
3151 if ($guessed_id_tax) {
3152 // update the id_tax values
3153 $id_tax = $guessed_id_tax;
3154 $roomData['id_tax'] = $guessed_id_tax;
3155 $this->setRoom($roomData, $index);
3156 }
3157 }
3158
3159 // apply taxes, if necessary
3160 if ($id_tax) {
3161 $taxdata = VBOTaxonomySummary::getTaxRateRecord((int) $id_tax);
3162 if (!empty($taxdata['aliq'])) {
3163 $aliq = $taxdata['aliq'];
3164 if (!VikBooking::ivaInclusa()) {
3165 // add tax to the total amount
3166 $subt = 100 + (float)$aliq;
3167 $roomTotalValue = ($roomTotalValue * $subt / 100);
3168 /**
3169 * Tax Cap implementation for prices tax excluded (most common).
3170 *
3171 * @since 1.12 (J) - 1.2 (WP)
3172 */
3173 if ($taxdata['taxcap'] > 0 && ($roomTotalValue - $cust_cost) > $taxdata['taxcap']) {
3174 $roomTotalValue = ($cust_cost + $taxdata['taxcap']);
3175 }
3176 // calculate tax
3177 $roomTotalTax = $roomTotalValue - $cust_cost;
3178 } else {
3179 // calculate tax
3180 $cost_minus_tax = VikBooking::sayPackageMinusIva($cust_cost, $id_tax);
3181 $roomTotalTax += ($cust_cost - $cost_minus_tax);
3182 }
3183 }
3184 }
3185 } elseif (!empty($id_price) && $room_cost > 0 && !$set_closed && empty($split_stay_data)) {
3186 // one website rate plan was selected, so we calculate total and taxes
3187 $roomTotalValue = $room_cost;
3188
3189 // find tax rate assigned to this rate plan
3190 $taxdata = VBOTaxonomySummary::getTaxRecordFromRatePlan($id_price);
3191 if (!empty($taxdata['aliq'])) {
3192 $aliq = $taxdata['aliq'];
3193 if (!VikBooking::ivaInclusa()) {
3194 // add tax to the total amount
3195 $subt = 100 + (float)$aliq;
3196 $roomTotalValue = ($roomTotalValue * $subt / 100);
3197 /**
3198 * Tax Cap implementation for prices tax excluded (most common).
3199 *
3200 * @since 1.12 (J) - 1.2 (WP)
3201 */
3202 if ($taxdata['taxcap'] > 0 && ($roomTotalValue - $room_cost) > $taxdata['taxcap']) {
3203 $roomTotalValue = ($room_cost + $taxdata['taxcap']);
3204 }
3205 // calculate tax
3206 $roomTotalTax = $roomTotalValue - $room_cost;
3207 } else {
3208 // calculate tax
3209 $cost_minus_tax = VikBooking::sayPackageMinusIva($room_cost, $taxdata['idiva']);
3210 $roomTotalTax += ($room_cost - $cost_minus_tax);
3211 }
3212 }
3213
3214 // total and taxes should be multiplied by the number of rooms booked when using a website rate plan
3215 if ($set_closed) {
3216 $roomTotalValue *= $roomDetails['units'];
3217 $roomTotalTax *= $roomDetails['units'];
3218 } elseif (!$this->isMultiRoom() && $num_rooms > 1 && $num_rooms <= $roomDetails['units']) {
3219 $roomTotalValue *= $num_rooms;
3220 $roomTotalTax *= $num_rooms;
3221 }
3222 }
3223
3224 // check for options/extras at room level
3225 if ($roomData['options'] ?? null) {
3226 // load all the eligible options for this room party
3227 $eligibleOptions = VBORoomHelper::getInstance()->getEligibleOptions($roomData['id'], [
3228 'checkin' => date('Y-m-d', ($roomData['checkin'] ?? null) ?: $this->get('checkin', 0)),
3229 'checkout' => date('Y-m-d', ($roomData['checkout'] ?? null) ?: $this->get('checkout', 0)),
3230 'adults' => $roomData['adults'] ?? $this->get('adults', 1),
3231 'children' => $roomData['children'] ?? $this->get('children', 0),
3232 'rate_id' => $roomData['id_price'] ?? null,
3233 ]);
3234 // obtain a list of eligible option IDs
3235 $eligibleOptIds = array_column($eligibleOptions, 'id');
3236 // get all eligible room options/extras that were set for booking
3237 $roomOptData = array_values(array_filter((array) $roomData['options'], function($roomOpt) use ($eligibleOptIds) {
3238 if (!is_array($roomOpt) || empty($roomOpt['id'])) {
3239 return false;
3240 }
3241 return in_array($roomOpt['id'], $eligibleOptIds) && (!empty($roomOpt['quantity']) || !empty($roomOpt['age_intervals']));
3242 }));
3243 if ($roomOptData) {
3244 // turn all eligible options into an associative list
3245 $eligibleOptions = array_combine(array_column($eligibleOptions, 'id'), array_values($eligibleOptions));
3246 // scan all room options to increase totals
3247 foreach ($roomOptData as $optData) {
3248 // obtain room option computed data
3249 $computedData = $eligibleOptions[$optData['id']] ?? [];
3250 // calculate option cost
3251 $roomOptCost = 0;
3252 if (!empty($optData['age_intervals'])) {
3253 // children age interval option
3254 foreach ((array) $optData['age_intervals'] as $childIndex => $ageIntervalIndex) {
3255 // child number is 0-based
3256 $childNumber = $childIndex + 1;
3257 // age interval index is 1-based
3258 $ageIntervalNumber = $ageIntervalIndex - 1;
3259 // check if a room cost is known
3260 $roomOptCost += (float) ($computedData['_computed_children_costs'][$childNumber][$ageIntervalNumber] ?? 0);
3261 }
3262 } else {
3263 // regular option
3264 $roomOptCost = floatval($computedData['_computed_cost'] ?? 0) * floatval($optData['quantity'] ?? 1);
3265 }
3266 // calculate gross and net costs
3267 $roomOptGross = VikBooking::sayOptionalsPlusIva($roomOptCost, $computedData['idiva'] ?? 0);
3268 $roomOptNet = VikBooking::sayOptionalsMinusIva($roomOptCost, $computedData['idiva'] ?? 0);
3269 // increase total value
3270 $roomTotalValue += $roomOptGross;
3271 // increase total tax
3272 $roomTotalTax += ($roomOptGross - $roomOptNet);
3273 if ($computedData['is_citytax'] ?? null) {
3274 // increase total city tax
3275 $roomTotalCityTax += $roomOptNet;
3276 } elseif ($computedData['is_fee'] ?? null) {
3277 // increase total fees
3278 $roomTotalFees += $roomOptNet;
3279 }
3280 }
3281 }
3282 }
3283
3284 // check for custom extra services at room level
3285 if ($roomData['extras'] ?? null) {
3286 $roomExtraCosts = array_values(array_filter((array) ($roomData['extras'] ?? []), function($extraCost) {
3287 return is_array($extraCost) && !empty($extraCost['name']);
3288 }));
3289 if ($roomExtraCosts) {
3290 // normalize values
3291 $roomExtraCosts = array_map(function($extraCost) {
3292 return [
3293 'name' => $extraCost['name'],
3294 'cost' => (float) ($extraCost['cost'] ?? 0),
3295 'idtax' => (int) ($extraCost['idtax'] ?? $extraCost['vat'] ?? 0),
3296 ];
3297 }, $roomExtraCosts);
3298 // scan all room extra services to increase totals
3299 foreach ($roomExtraCosts as $roomExtraCost) {
3300 $ecplustax = !empty($roomExtraCost['idtax']) ? VikBooking::sayOptionalsPlusIva($roomExtraCost['cost'], $roomExtraCost['idtax']) : $roomExtraCost['cost'];
3301 $ecminustax = !empty($roomExtraCost['idtax']) ? VikBooking::sayOptionalsMinusIva($roomExtraCost['cost'], $roomExtraCost['idtax']) : $roomExtraCost['cost'];
3302 // increase total value
3303 $roomTotalValue += $ecplustax;
3304 // increase total tax
3305 $roomTotalTax += ($ecplustax - $ecminustax);
3306 }
3307 }
3308 }
3309
3310 // increase global totals
3311 $set_total += $roomTotalValue;
3312 $set_taxes += $roomTotalTax;
3313 $set_city_tax += $roomTotalCityTax;
3314 $set_fees += $roomTotalFees;
3315 }
3316
3317 // set values
3318 $this->set('_total', $set_total);
3319 $this->set('_total_tax', $set_taxes);
3320 $this->set('_total_city_tax', $set_city_tax);
3321 $this->set('_total_fees', $set_fees);
3322
3323 // return the list of calculated values
3324 return [
3325 $set_total,
3326 $set_taxes,
3327 $set_city_tax,
3328 $set_fees,
3329 ];
3330 }
3331
3332 /**
3333 * Stores (or updates) the booking and room-booking records.
3334 * If no errors, the newly generated or updated booking id is set.
3335 *
3336 * @param array $rooms_pool List of rooms involved.
3337 * @param ?int $bookingId The booking ID being updated, if any.
3338 *
3339 * @return bool
3340 *
3341 * @since 1.18.11 (J) - 1.8.11 (WP) added $bookingId argument to support updates.
3342 */
3343 protected function storeReservationRecords(array $rooms_pool, ?int $bookingId = null)
3344 {
3345 if ($bookingId && !$this->prevBookingRegistry) {
3346 $this->setError('Missing previous booking registry for update.');
3347 return false;
3348 }
3349
3350 $dbo = JFactory::getDbo();
3351
3352 // access properties
3353 $set_closed = $this->get('set_closed', 0);
3354 $units_closed = $this->get('units_closed', 0);
3355 $daysdiff = (int) $this->get('nights', 1);
3356 $num_rooms = (int) $this->get('num_rooms', 1);
3357 $adults = (int) $this->get('adults', 1);
3358 $children = (int) $this->get('children', 0);
3359 $children_age = (array) $this->get('children_age', []);
3360 $status = $this->get('status', 'confirmed');
3361
3362 $split_stay_data = $this->get('split_stay', []);
3363 $room = $this->getRoomDetails();
3364 if (!$room || !$rooms_pool) {
3365 return false;
3366 }
3367
3368 // access all rooms data
3369 $roomsData = $this->getRooms();
3370
3371 // get current Joomla/WordPress User ID
3372 $now_user = JFactory::getUser();
3373 $store_ujid = property_exists($now_user, 'id') ? (int)$now_user->id : 0;
3374
3375 // forced booking reason, status validation and additional data
3376 $forced_reason = $this->get('forced_reason', '');
3377 $valid_statuses = ['confirmed', 'standby'];
3378 $status = in_array($status, $valid_statuses) ? $status : 'confirmed';
3379 $paymentmeth = $this->get('id_payment', '');
3380 $auto_paymeth = (bool) $this->get('auto_payment_method', false);
3381 $set_total = (float) $this->get('_total', 0);
3382 $set_taxes = (float) $this->get('_total_tax', 0);
3383 $set_city_tax = (float) $this->get('_total_city_tax', 0);
3384 $set_fees = (float) $this->get('_total_fees', 0);
3385
3386 if ($bookingId) {
3387 // updating a booking should not change the creation date
3388 $now_ts = $this->prevBookingRegistry->getProperty('ts', time());
3389 } else {
3390 // booking creation date
3391 $now_ts = time();
3392 if ($created_on = $this->get('created_on')) {
3393 if (is_int($created_on)) {
3394 // timestamp expected
3395 $now_ts = $created_on;
3396 } elseif (preg_match('/^[0-9]{4}\-[0-9]{2}\-[0-9]{2}/', (string) $created_on)) {
3397 // date in military format expected, with or without the time
3398 $now_ts = strtotime($created_on);
3399 }
3400 }
3401 }
3402
3403 // stay dates
3404 $checkin_ts = $this->get('checkin');
3405 $checkout_ts = $this->get('checkout');
3406 $realback_ts = $this->get('checkout_real', $checkout_ts);
3407
3408 // customer
3409 $cpin = VikBooking::getCPinInstance();
3410 $inj_customer = $this->getCustomer();
3411 $customer_id = ($inj_customer['id'] ?? null) ?: 0;
3412 $customer_pin = ($inj_customer['pin'] ?? null) ?: '';
3413 $t_first_name = ($inj_customer['first_name'] ?? null) ?: '';
3414 $t_last_name = ($inj_customer['last_name'] ?? null) ?: '';
3415 $customer_data = ($inj_customer['data'] ?? null) ?: '';
3416 $customer_email = ($inj_customer['email'] ?? null) ?: '';
3417 $country_code = ($inj_customer['country'] ?? null) ?: '';
3418 $phone_number = ($inj_customer['phone'] ?? null) ?: '';
3419 $phone_alias = ($inj_customer['phone_alias'] ?? null) ?: '';
3420 $address = ($inj_customer['address'] ?? null) ?: '';
3421 $city = ($inj_customer['city'] ?? null) ?: '';
3422 $zip = ($inj_customer['zip'] ?? null) ?: '';
3423
3424 if ($set_closed) {
3425 // get the possibly custom "customer notes"
3426 $customer_notes = $customer_data;
3427 // overwrite "customer notes" to "Room Closed"
3428 $customer_data = JText::translate('VBDBTEXTROOMCLOSED');
3429 if ($customer_notes != $customer_data) {
3430 // append the custom "custom notes" to the administrator notes instead
3431 $custom_admin_notes = trim($this->get('admin_notes', '') . "\n" . $customer_notes);
3432 $this->set('admin_notes', $custom_admin_notes);
3433 }
3434 }
3435
3436 // check for default customer raw data
3437 if (!$customer_data && $t_first_name) {
3438 // build a default raw data string
3439 $customer_data = "Name: {$t_first_name}\n";
3440 if ($t_last_name) {
3441 $customer_data .= "Last Name: {$t_last_name}\n";
3442 }
3443 if ($customer_email) {
3444 $customer_data .= "eMail: {$customer_email}\n";
3445 }
3446 if ($phone_number) {
3447 $customer_data .= "Phone: {$phone_number}\n";
3448 } elseif ($phone_alias) {
3449 $customer_data .= "Phone-alias: {$phone_alias}\n";
3450 }
3451 if ($country_code) {
3452 $customer_data .= "Country: {$country_code}\n";
3453 }
3454 if ($address) {
3455 $customer_data .= "Address: {$address}\n";
3456 }
3457 if ($city) {
3458 $customer_data .= "City: {$city}\n";
3459 }
3460 if ($zip) {
3461 $customer_data .= "Zip: {$zip}\n";
3462 }
3463 $customer_data = rtrim($customer_data, "\n");
3464 }
3465
3466 // ensure the country code is in the right format
3467 if (!empty($country_code)) {
3468 $country_code = $cpin->get3CharCountry($country_code);
3469 }
3470
3471 // ensure we have a valid country ISO code
3472 if (strlen((string) $country_code) > 3) {
3473 // this looks like an unknown country name
3474 $country_code = '';
3475 }
3476
3477 // generate booking SID, unless we are updating
3478 $sid = !$bookingId ? VikBooking::getSecretLink() : null;
3479
3480 // assign room specific unit
3481 $set_room_indexes = !$set_closed ? VikBooking::autoRoomUnit() : false;
3482 $num_rooms = $num_rooms > 0 ? $num_rooms : 1;
3483
3484 // occupancy and loop limits
3485 $forend = 1;
3486 $or_forend = 1;
3487 $adults_map = [];
3488 $children_map = [];
3489 if ($set_closed && empty($split_stay_data)) {
3490 $forend = $room['units'];
3491 } elseif ($num_rooms > 1 && $num_rooms <= $room['units'] && empty($split_stay_data)) {
3492 $forend = $num_rooms;
3493 $or_forend = $num_rooms;
3494 // assign adults/children proportionally
3495 if (($adults + $children) < $num_rooms) {
3496 // the number of guests does not make much sense but we build the maps anyway
3497 for ($r = 1; $r <= $or_forend; $r++) {
3498 $adults_map[$r] = $adults;
3499 $children_map[$r] = $children;
3500 }
3501 } else {
3502 $adults_per_room = floor(($adults / $num_rooms));
3503 $adults_left = ($adults % $num_rooms);
3504 $children_per_room = floor(($children / $num_rooms));
3505 $children_left = ($children % $num_rooms);
3506 for ($r = 1; $r <= $or_forend; $r++) {
3507 $adults_map[$r] = $adults_per_room;
3508 $children_map[$r] = $children_per_room;
3509 if ($r == $or_forend) {
3510 $adults_map[$r] += $adults_left;
3511 $children_map[$r] += $children_left;
3512 }
3513 }
3514 }
3515 }
3516
3517 if ($this->isMultiRoom()) {
3518 // adjust values
3519 $forend = 1;
3520 $or_forend = 1;
3521 }
3522
3523 // count total rooms booked
3524 $totalrooms = (($set_closed && $status == 'confirmed') || $this->isMultiRoom() ? count($rooms_pool) : ($num_rooms > 1 && $num_rooms <= $room['units'] ? $num_rooms : 1));
3525 $totalrooms = !empty($split_stay_data) ? count($split_stay_data) : $totalrooms;
3526
3527 // attempt to get the default payment method
3528 if (!$paymentmeth && ($auto_paymeth || $status == 'standby')) {
3529 // get the default payment method, if any
3530 $paymentmeth = $this->getDefaultPaymentMethod($auto_paymeth);
3531 } elseif ($paymentmeth && is_int($paymentmeth)) {
3532 // convert the payment integer ID into a string with the actual record name
3533 $paymentmeth = $this->getPaymentMethodValue($paymentmeth);
3534 }
3535
3536 if ($bookingId) {
3537 // when updating, delete all room booking records involved
3538 // to allow the re-generation of the updated records
3539 $this->delete(['booking_id' => $bookingId], $reCreating = true);
3540 }
3541
3542 // prepare booking record
3543 $booking = new stdClass;
3544
3545 if ($bookingId) {
3546 // inject current booking ID for update
3547 $booking->id = $bookingId;
3548 }
3549
3550 // set booking record properties
3551 $booking->custdata = $customer_data;
3552 $booking->ts = $now_ts;
3553 $booking->status = $status;
3554 $booking->days = $daysdiff;
3555 $booking->checkin = $checkin_ts;
3556 $booking->checkout = $checkout_ts;
3557 $booking->custmail = $customer_email;
3558 $booking->sid = $sid;
3559 $booking->idpayment = $paymentmeth;
3560 $booking->ujid = (int)$store_ujid;
3561 $booking->roomsnum = $totalrooms;
3562 $booking->total = $set_total ?: null;
3563 if ($this->get('admin_notes')) {
3564 $booking->adminnotes = $this->get('admin_notes', '');
3565 }
3566 $booking->lang = VikBooking::guessBookingLangFromCountry($country_code);
3567 $booking->country = $country_code;
3568 $booking->tot_taxes = $set_taxes ?: null;
3569 $booking->tot_city_taxes = $set_city_tax ?: ($this->get('tot_city_taxes') ? ((float) $this->get('tot_city_taxes')) : null);
3570 $booking->tot_fees = $set_fees ?: ($this->get('tot_fees') ? ((float) $this->get('tot_fees')) : null);
3571 $booking->tot_damage_dep = $this->get('tot_damage_dep') ? ((float) $this->get('tot_damage_dep')) : null;
3572 $booking->phone = $phone_number;
3573 $booking->phone_alias = $phone_alias ?: null;
3574 $booking->cmms = $this->get('cmms') ? ((float) $this->get('cmms')) : null;
3575 $booking->closure = ($status == 'standby' ? 0 : ($set_closed || $units_closed ? 1 : 0));
3576 $booking->payable = $this->get('payable') ? ((float) $this->get('payable')) : null;
3577 $booking->refund = $this->get('refund') ? ((float) $this->get('refund')) : null;
3578 $booking->type = $this->get('type') ? ((string) $this->get('type')) : null;
3579 $booking->ota_type_data = $this->get('ota_type_data') && !is_scalar($this->get('ota_type_data')) ? json_encode($this->get('ota_type_data')) : null;
3580 $booking->split_stay = !empty($split_stay_data) ? 1 : 0;
3581 $booking->canc_fee = $this->get('canc_fee') ? ((float) $this->get('canc_fee')) : null;
3582 $booking->idquote = $this->get('idquote') ? ((int) $this->get('idquote')) : null;
3583
3584 // process reservation
3585 if ($status == 'confirmed') {
3586 // occupy room records first, when status is confirmed
3587 $insertedbusy = [];
3588 if (empty($split_stay_data)) {
3589 // only when closing other rooms we have an array containing multiple rooms info
3590 foreach ($rooms_pool as $index => $nowroom) {
3591 // determine the number of records to occupy
3592 $nowforend = $set_closed ? $nowroom['units'] : $forend;
3593 // determine the timestamps to occupy by supporting room-level stay dates
3594 $room_checkin_ts = $roomsData[$index]['checkin'] ?? $checkin_ts;
3595 $room_checkout_ts = $roomsData[$index]['checkout'] ?? $checkout_ts;
3596 $room_realback_ts = $roomsData[$index]['realback'] ?? $roomsData[$index]['checkout'] ?? $realback_ts;
3597 // occupy records
3598 for ($b = 1; $b <= $nowforend; $b++) {
3599 $busy_record = new stdClass;
3600 $busy_record->idroom = (int) $nowroom['id'];
3601 $busy_record->checkin = (int) $room_checkin_ts;
3602 $busy_record->checkout = (int) $room_checkout_ts;
3603 $busy_record->realback = (int) $room_realback_ts;
3604
3605 // store busy record
3606 $dbo->insertObject('#__vikbooking_busy', $busy_record, 'id');
3607
3608 if (isset($busy_record->id)) {
3609 $insertedbusy[] = $busy_record->id;
3610 }
3611 }
3612 }
3613 } else {
3614 // for split stay bookings we occupy the rooms on the individual stay dates
3615 foreach ($split_stay_data as $split_stay) {
3616 $busy_record = new stdClass;
3617 $busy_record->idroom = (int) $split_stay['idroom'];
3618 $busy_record->checkin = (int) $split_stay['checkin_ts'];
3619 $busy_record->checkout = (int) $split_stay['checkout_ts'];
3620 $busy_record->realback = (int) $split_stay['realback_ts'];
3621
3622 // store busy record
3623 $dbo->insertObject('#__vikbooking_busy', $busy_record, 'id');
3624
3625 if (isset($busy_record->id)) {
3626 $insertedbusy[] = $busy_record->id;
3627 }
3628 }
3629 }
3630
3631 if (!$insertedbusy) {
3632 $this->setError('No records were occupied');
3633 return false;
3634 }
3635 }
3636
3637 if ($bookingId) {
3638 // update booking record
3639 $dbo->updateObject('#__vikbooking_orders', $booking, 'id');
3640 } else {
3641 // store booking record
3642 $dbo->insertObject('#__vikbooking_orders', $booking, 'id');
3643 }
3644
3645 if (empty($booking->id)) {
3646 $this->setError('Could not save reservation record.');
3647 return false;
3648 }
3649
3650 // get the newly generated (or just updated) booking ID
3651 $newoid = $booking->id;
3652
3653 // set the new booking ID
3654 $this->setNewBookingID($newoid);
3655
3656 if (!empty($split_stay_data)) {
3657 // save transient on db for split stay information
3658 VBOFactory::getConfig()->set('split_stay_' . $newoid, json_encode($split_stay_data));
3659 }
3660
3661 if ($status == 'confirmed') {
3662 // check if some of the rooms booked have shared calendars
3663 VikBooking::updateSharedCalendars($newoid, [$room['id']], $checkin_ts, $checkout_ts);
3664
3665 if (!$bookingId) {
3666 // generate and set confirmation number for the newly created confirmed reservation
3667 $confirmnumber = VikBooking::generateConfirmNumber($newoid, true);
3668 }
3669
3670 // store busy record-booking relations
3671 foreach ($insertedbusy as $lid) {
3672 $obusy_record = new stdClass;
3673 $obusy_record->idorder = (int) $newoid;
3674 $obusy_record->idbusy = (int) $lid;
3675
3676 // store busy relation record
3677 $dbo->insertObject('#__vikbooking_ordersbusy', $obusy_record, 'id');
3678 }
3679 }
3680
3681 // store room booking records
3682 $room_indexes_usemap = [];
3683 foreach ($rooms_pool as $rind => $nowroom) {
3684 for ($r = 1; $r <= $or_forend; $r++) {
3685 // determine room-level stay dates
3686 $room_stay_checkin = $nowroom['checkin_ts'] ?? $roomsData[$rind]['checkin'] ?? $checkin_ts;
3687 $room_stay_checkout = $nowroom['checkout_ts'] ?? $roomsData[$rind]['checkout'] ?? $checkout_ts;
3688 $room_stay_realback = $roomsData[$rind]['realback'] ?? $roomsData[$rind]['checkout'] ?? $realback_ts;
3689
3690 // check booking status for sub-units
3691 if ($status == 'confirmed') {
3692 // assign room specific unit
3693 $info_room_avail = [
3694 'id' => $newoid,
3695 'checkin' => $nowroom['checkin_ts'] ?? $roomsData[$rind]['checkin'] ?? $checkin_ts,
3696 'checkout' => $nowroom['checkout_ts'] ?? $roomsData[$rind]['checkout'] ?? $checkout_ts,
3697 ];
3698 $room_indexes = $set_room_indexes === true ? VikBooking::getRoomUnitNumsAvailable($info_room_avail, $nowroom['id']) : [];
3699 $use_ind_key = 0;
3700 if ($room_indexes) {
3701 if (!array_key_exists($nowroom['id'], $room_indexes_usemap)) {
3702 $room_indexes_usemap[$nowroom['id']] = $use_ind_key;
3703 } else {
3704 $use_ind_key = $room_indexes_usemap[$nowroom['id']];
3705 }
3706 }
3707 }
3708
3709 // gather room-level information
3710 $cust_cost = (float) ($roomsData[$rind]['cust_cost'] ?? 0);
3711 $room_cost = (float) ($roomsData[$rind]['room_cost'] ?? 0);
3712 $id_price = (int) ($roomsData[$rind]['id_price'] ?? 0);
3713 $id_tax = (int) ($roomsData[$rind]['id_tax'] ?? 0);
3714 $id_tariff = $this->loadTariffID($roomsData[$rind]);
3715 $cust_indexes = (array) ($roomsData[$rind]['cust_indexes'] ?? []);
3716 $totalpnight = ($roomsData[$rind]['total_or_pnight'] ?? null) ?: 'total';
3717 if ($cust_cost > 0.00 && !$set_closed && $totalpnight == 'pnight') {
3718 // custom rate modifier per night
3719 $cust_cost = $cust_cost * $daysdiff;
3720 }
3721 $cust_cpolicy_id = (int) ($roomsData[$rind]['cust_cpolicy_id'] ?? 0);
3722
3723 // room custom cost
3724 $or_cust_cost = $cust_cost > 0.00 ? $cust_cost : 0;
3725 $or_cust_cost = $or_forend > 1 && $or_cust_cost > 0 ? round(($or_cust_cost / $or_forend), 2) : $or_cust_cost;
3726 // room cost from website rate plan is always based on one room
3727 $or_room_cost = $room_cost > 0.00 && !$or_cust_cost ? $room_cost : 0;
3728 if (!empty($split_stay_data) && $cust_cost > 0) {
3729 // set the average cost per room in case of split stay
3730 $cost_per_room = ($cust_cost / count($split_stay_data));
3731 $or_cust_cost = round($cost_per_room, 2);
3732 if (isset($split_stay_data[$rind]['nights'])) {
3733 // count the average cost per room depending on the number of nights of stay
3734 $cost_per_room = $cust_cost / $daysdiff * $split_stay_data[$rind]['nights'];
3735 $or_cust_cost = round($cost_per_room, 2);
3736 }
3737 }
3738
3739 // room guests
3740 $room_adults = (int) ($roomsData[$rind]['adults'] ?? (isset($adults_map[$r]) && empty($split_stay_data) ? $adults_map[$r] : $adults));
3741 $room_children = (int) ($roomsData[$rind]['children'] ?? (isset($children_map[$r]) && empty($split_stay_data) ? $children_map[$r] : $children));
3742 $use_children_age = (array) ($roomsData[$rind]['children_age'] ?? $children_age);
3743
3744 // number of pets per room
3745 $room_pets = (int) ($roomsData[$rind]['pets'] ?? 0);
3746
3747 // attempt to gather the children age for this room
3748 $room_children_age = null;
3749 if ($room_children && $use_children_age) {
3750 $children_age_pool = [];
3751 for ($ic = 0; $ic < $room_children; $ic++) {
3752 if (!$use_children_age) {
3753 $children_age_pool[] = 0;
3754 continue;
3755 }
3756 // shorten the list and push the current child age
3757 $current_child_age = array_shift($use_children_age);
3758 $children_age_pool[] = (int) $current_child_age;
3759 }
3760 $room_children_age = json_encode(['age' => $children_age_pool]);
3761 }
3762
3763 // determine room index to use, if any
3764 $roomindex = null;
3765 if ($status == 'confirmed') {
3766 if ($cust_indexes[$use_ind_key] ?? null) {
3767 // use a custom room index
3768 $roomindex = (int) $cust_indexes[$use_ind_key];
3769 } elseif ($room_indexes) {
3770 $roomindex = (int) $room_indexes[$use_ind_key];
3771 }
3772 }
3773
3774 // handle room record options/extras
3775 $roomOptStr = null;
3776 if ($roomsData[$rind]['options'] ?? null) {
3777 $roomOptData = array_values(array_filter((array) $roomsData[$rind]['options'], function($roomOpt) {
3778 return is_array($roomOpt) && !empty($roomOpt['id']) && (!empty($roomOpt['quantity']) || !empty($roomOpt['age_intervals']));
3779 }));
3780 // normalize values
3781 $roomOptData = array_map(function($roomOpt) {
3782 // base option string with ID and quantity
3783 $baseOptStr = sprintf('%d:%d', (int) $roomOpt['id'], (int) (($roomOpt['quantity'] ?? 0) ?: 1));
3784 if (!empty($roomOpt['age_intervals'])) {
3785 $ageData = [];
3786 foreach ((array) $roomOpt['age_intervals'] as $ageIntervalIndex) {
3787 $ageData[] = $baseOptStr . '-' . $ageIntervalIndex;
3788 }
3789 $finalOptStr = implode(';', $ageData);
3790 } else {
3791 $finalOptStr = $baseOptStr;
3792 }
3793 return $finalOptStr;
3794 }, $roomOptData);
3795 if ($roomOptData) {
3796 // obtain the value to save
3797 $roomOptStr = implode(';', $roomOptData) . ';';
3798 }
3799 }
3800
3801 // handle room record extra costs
3802 $roomExtraCosts = array_values(array_filter((array) ($roomsData[$rind]['extras'] ?? []), function($extraCost) {
3803 return is_array($extraCost) && !empty($extraCost['name']);
3804 }));
3805 if ($roomExtraCosts) {
3806 // normalize values
3807 $roomExtraCosts = array_map(function($extraCost) {
3808 return [
3809 'name' => $extraCost['name'],
3810 'cost' => (float) ($extraCost['cost'] ?? 0),
3811 'idtax' => (int) ($extraCost['idtax'] ?? $extraCost['vat'] ?? 0),
3812 ];
3813 }, $roomExtraCosts);
3814 }
3815
3816 // store room record
3817 $room_record = new stdClass;
3818 $room_record->idorder = (int) $newoid;
3819 $room_record->idroom = (int) $nowroom['id'];
3820 $room_record->adults = $room_adults;
3821 $room_record->children = $room_children;
3822 $room_record->pets = $room_pets ?: 0;
3823 $room_record->idtar = $id_tariff ?: null;
3824 $room_record->optionals = $roomOptStr ?: null;
3825 $room_record->childrenage = $room_children_age;
3826 $room_record->t_first_name = $t_first_name;
3827 $room_record->t_last_name = $t_last_name;
3828 $room_record->roomindex = $roomindex;
3829 $room_record->cust_cost = $cust_cost > 0 ? $or_cust_cost : null;
3830 $room_record->cust_idiva = $cust_cost > 0 && !empty($id_tax) ? $id_tax : null;
3831 $room_record->cust_cpolicy_id = $cust_cpolicy_id ?: null;
3832 $room_record->extracosts = $roomExtraCosts ? json_encode($roomExtraCosts) : null;
3833 $room_record->room_cost = $or_room_cost > 0 ? $or_room_cost : null;
3834
3835 $dbo->insertObject('#__vikbooking_ordersrooms', $room_record, 'id');
3836
3837 if (empty($room_record->id)) {
3838 $this->setError('Could not store room reservation record for booking ID ' . $room_record->idorder);
3839 continue;
3840 }
3841
3842 if ($status == 'confirmed') {
3843 // assign room specific unit
3844 if ($room_indexes) {
3845 $room_indexes_usemap[$nowroom['id']]++;
3846 }
3847 } elseif ($status == 'standby' && empty($split_stay_data)) {
3848 // lock room for pending status when NO split-stay data by supporting room-level dates
3849 $lockUntil = VikBooking::getMinutesLock(true);
3850
3851 if ($this->get('lock_until')) {
3852 // convert expected date-time string into a timestamp for custom lock until date
3853 $lockUntil = strtotime((string) $this->get('lock_until')) ?: $lockUntil;
3854 }
3855
3856 if ($lockUntil && $this->get('dont_lock') !== true) {
3857 // store room lock record
3858 $tmplock_record = new stdClass;
3859 $tmplock_record->idroom = (int) $nowroom['id'];
3860 $tmplock_record->checkin = $room_stay_checkin;
3861 $tmplock_record->checkout = $room_stay_checkout;
3862 $tmplock_record->until = $lockUntil;
3863 $tmplock_record->realback = $room_stay_realback;
3864 $tmplock_record->idorder = (int) $newoid;
3865
3866 $dbo->insertObject('#__vikbooking_tmplock', $tmplock_record, 'id');
3867 }
3868 }
3869 }
3870 }
3871
3872 if ($status == 'standby' && !empty($split_stay_data)) {
3873 // lock split-stay rooms for pending status on proper stay dates
3874 foreach ($split_stay_data as $split_stay) {
3875 $tmplock_record = new stdClass;
3876 $tmplock_record->idroom = (int) $split_stay['idroom'];
3877 $tmplock_record->checkin = (int) $split_stay['checkin_ts'];
3878 $tmplock_record->checkout = (int) $split_stay['checkout_ts'];
3879 $tmplock_record->until = VikBooking::getMinutesLock(true);
3880 $tmplock_record->realback = (int) $split_stay['realback_ts'];
3881 $tmplock_record->idorder = (int) $newoid;
3882
3883 $dbo->insertObject('#__vikbooking_tmplock', $tmplock_record, 'id');
3884 }
3885 }
3886
3887 // assign booking to customer
3888 if (!$cpin->getNewCustomerId() && !empty($customer_id)) {
3889 $cpin->setNewPin($customer_pin);
3890 $cpin->setNewCustomerId($customer_id);
3891 }
3892 $cpin->saveCustomerBooking($newoid);
3893
3894 // handle booking history
3895 $history_obj = VikBooking::getBookingHistoryInstance($newoid);
3896
3897 $forced_reason = !empty($forced_reason) ? " {$forced_reason}" : $forced_reason;
3898 $caller_id = $now_user->name ? "({$now_user->name})" : '';
3899 if ($this->getCaller()) {
3900 $caller_id = '(' . $this->getCaller() . ')';
3901 if ($this->getHistoryData()) {
3902 $history_obj->setExtraData($this->getHistoryData());
3903 }
3904 }
3905 if ($this->get('idquote')) {
3906 // mention the quote ID in the history description
3907 $forced_reason .= sprintf(' %s #%d', JText::translate('VBO_BTYPE_QUOTE'), (int) $this->get('idquote'));
3908 }
3909
3910 // tell whether alterations were detected, in case of booking update
3911 $alterationsDetected = false;
3912
3913 if ($bookingId) {
3914 // booking updated event
3915 $historyPrevBooking = $this->prevBookingRegistry->getData();
3916 $historyPrevBooking['rooms_info'] = $this->prevBookingRegistry->getRooms();
3917 $history_obj
3918 ->setPrevBooking($historyPrevBooking)
3919 ->store('MB', trim($caller_id . $forced_reason) . ' ' . VikBooking::getLogBookingModification($historyPrevBooking));
3920
3921 // construct a new booking registry, and inject the booking snapshot prior updating
3922 $currentRegistry = VBOBookingRegistry::getInstance(['id' => $bookingId], [], $historyPrevBooking);
3923 // detect alterations (include room-level)
3924 $alterationsDetected = $currentRegistry->detectAlterations(true);
3925 } else {
3926 // new booking event
3927 $history_obj->store('NB', trim($caller_id . $forced_reason));
3928 }
3929
3930 if ($status == 'confirmed' || ($status == 'standby' && class_exists('VCMRequestAvailability'))) {
3931 // Invoke Channel Manager
3932 $vcm_autosync = VikBooking::vcmAutoUpdate();
3933 if ($vcm_autosync > 0) {
3934 $vcm_obj = VikBooking::getVcmInvoker();
3935
3936 // tell if sync is actually needed
3937 $sync_needed = true;
3938 if ($bookingId && $alterationsDetected === false) {
3939 // do not waste connections when nothing sensitive was updated
3940 $sync_needed = false;
3941 }
3942
3943 if ($sync_needed) {
3944 if ($bookingId) {
3945 // sync for an updated booking, after something was truly modified
3946 $vcm_obj->setOids([$bookingId])->setSyncType('modify')->setOriginalBooking($historyPrevBooking ?? []);
3947 } else {
3948 // always sync for a new booking
3949 $vcm_obj->setOids([$newoid])->setSyncType('new');
3950 }
3951
3952 // run operation
3953 $sync_result = $vcm_obj->doSync();
3954
3955 if ($sync_result === false) {
3956 // set error message, but do NOT return false at this point
3957 $vcm_err = $vcm_obj->getError();
3958 $this->setError(JText::translate('VBCHANNELMANAGERRESULTKO') . (!empty($vcm_err) ? ' - ' . $vcm_err : ''));
3959 }
3960 }
3961 } elseif (!$bookingId && is_file(VCM_SITE_PATH . DIRECTORY_SEPARATOR . 'helpers' . DIRECTORY_SEPARATOR . 'synch.vikbooking.php')) {
3962 // set the necessary action to invoke VCM (only when creating a new booking)
3963 $vcm_sync_url = 'index.php?option=com_vikbooking&task=invoke_vcm&stype=new&cid[]=' . $newoid . '&returl=' . urlencode('index.php?option=com_vikbooking&task=calendar&cid[]=' . $room['id']);
3964
3965 $this->setChannelManagerAction(JText::translate('VBCHANNELMANAGERINVOKEASK') . ' <button type="button" class="btn btn-primary" onclick="document.location.href=\'' . $vcm_sync_url . '\';">' . JText::translate('VBCHANNELMANAGERSENDRQ') . '</button>');
3966 }
3967 }
3968
3969 if (VikBooking::isAdmin()) {
3970 /**
3971 * Trigger event to allow third party plugins to intercept the admin new/updated booking event.
3972 *
3973 * @since 1.16.8 (J) - 1.6.8 (WP)
3974 */
3975 VBOFactory::getPlatform()->getDispatcher()->trigger($bookingId ? 'onAfterUpdateBookingAdmin' : 'onAfterCreateNewBookingAdmin', [$newoid]);
3976 }
3977
3978 return true;
3979 }
3980
3981 /**
3982 * Attempts to find the default payment method to be assigned to a booking.
3983 *
3984 * @param bool $auto If true, it was requested to automatically find the best payment method.
3985 *
3986 * @return string The payment method string for the reservation "ID=Name", or an empty string.
3987 *
3988 * @since 1.17.3 (J) - 1.7.3 (WP)
3989 */
3990 protected function getDefaultPaymentMethod($auto = false)
3991 {
3992 $dbo = JFactory::getDbo();
3993
3994 $dbo->setQuery(
3995 $dbo->getQuery(true)
3996 ->select('*')
3997 ->from($dbo->qn('#__vikbooking_gpayments'))
3998 ->order($dbo->qn('published') . ' DESC')
3999 ->order($dbo->qn('ordering') . ' ASC')
4000 ->order($dbo->qn('setconfirmed') . ' ASC')
4001 ->order($dbo->qn('name') . ' ASC')
4002 );
4003
4004 $methods = $dbo->loadAssocList();
4005
4006 if ($auto) {
4007 // exclude all the offline or unpublished payment methods
4008 $methods = array_filter($methods, function($method) {
4009 return !((bool) intval($method['setconfirmed'])) && (bool) intval($method['published']);
4010 });
4011
4012 // reset array keys
4013 $methods = array_values($methods);
4014 }
4015
4016 if ($methods) {
4017 // default payment method found
4018 return sprintf('%s=%s', $methods[0]['id'], $methods[0]['name']);
4019 }
4020
4021 // nothing was found
4022 return '';
4023 }
4024
4025 /**
4026 * Given a payment method record ID, returns the string
4027 * in the format for saving it along the reservation.
4028 *
4029 * @param int $id The record ID.
4030 *
4031 * @return ?string ID=Name calculated value, or null.
4032 *
4033 * @since 1.18.15 (J) - 1.8.15 (WP)
4034 */
4035 protected function getPaymentMethodValue(int $id)
4036 {
4037 $record = VBOMvcModel::getInstance('payment')->getItem($id);
4038
4039 if ($record) {
4040 return sprintf('%s=%s', $record->id, $record->name);
4041 }
4042
4043 return null;
4044 }
4045 }
4046