PluginProbe
VikBooking Hotel Booking Engine & PMS / trunk
VikBooking Hotel Booking Engine & PMS vtrunk
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 1.7.3 All 36 releases
vikbooking / admin / helpers / src / model / reservation.php

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

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