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