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 +3590 -2784 1.8.7 → 1.8.16 View file →
@@ -13,3227 +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 $times_loaded;
299 - }
292 + /**
293 + * Returns the current room information.
294 + *
295 + * @return array
296 + */
297 + public function getRoom()
298 + {
299 + return (array) $this->get('_room', []);
300 + }
300 301
301 - $timeopst = VikBooking::getTimeOpenStore();
302 - if (is_array($timeopst) && $timeopst) {
303 - $opent = VikBooking::getHoursMinutes($timeopst[0]);
304 - $closet = VikBooking::getHoursMinutes($timeopst[1]);
305 - $hcheckin = $opent[0];
306 - $mcheckin = $opent[1];
307 - $hcheckout = $closet[0];
308 - $mcheckout = $closet[1];
309 - } else {
310 - $hcheckin = 0;
311 - $mcheckin = 0;
312 - $hcheckout = 0;
313 - $mcheckout = 0;
314 - }
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 + }
315 313
316 - $this->set('checkin_h', $hcheckin);
317 - $this->set('checkin_m', $mcheckin);
318 - $this->set('checkout_h', $hcheckout);
319 - $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);
320 324
321 - $times_loaded = [
322 - $hcheckin,
323 - $mcheckin,
324 - $hcheckout,
325 - $mcheckout,
326 - ];
325 + return $this;
326 + }
327 327
328 - return $times_loaded;
329 - }
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 + }
330 337
331 - /**
332 - * Attempts to extract the Special Requests from the customer raw data.
333 - *
334 - * @return string
335 - *
336 - * @since 1.16.5 (J) - 1.6.5 (WP)
337 - */
338 - public function extractSpecialRequests()
339 - {
340 - $raw_cust_data = $this->get('custdata', '');
341 - if (empty($raw_cust_data)) {
342 - return '';
343 - }
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);
344 348
345 - $special_requests = '';
346 - if (preg_match("/(?:special_?requests:\s*)(.*?)$/is", $raw_cust_data, $match)) {
347 - $special_requests = $match[1];
348 - } elseif (preg_match("/(?:special_?request:\s*)(.*?)$/is", $raw_cust_data, $match)) {
349 - $special_requests = $match[1];
350 - } elseif (preg_match("/(?:special_?request\s*)(.*?)$/is", $raw_cust_data, $match)) {
351 - $special_requests = $match[1];
352 - } elseif (preg_match("/(?:" . JText::translate('ORDER_SPREQUESTS') . ":\s*)(.*?)$/is", $raw_cust_data, $match)) {
353 - $special_requests = $match[1];
354 - }
349 + return $this;
350 + }
355 351
356 - return $special_requests;
357 - }
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 + }
358 361
359 - /**
360 - * Creates a new reservation record after having constructed the
361 - * object by properly injecting all the necessary booking information.
362 - *
363 - * @return bool
364 - */
365 - public function create()
366 - {
367 - if (!$this->canCreate()) {
368 - $this->setError('Forbidden');
369 - return false;
370 - }
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 + }
371 386
372 - // availability helper
373 - $av_helper = VikBooking::getAvailabilityInstance(true);
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);
374 392
375 - // validate mandatory fields
376 - $room = $this->getRoom();
377 - if (!$this->get('checkin') || !$this->get('checkout') || empty($room['id'])) {
378 - $this->setError('Missing mandatory fields');
379 - return false;
380 - }
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 + }
381 414
382 - if ($this->get('checkin') >= $this->get('checkout')) {
383 - $this->setError('Invalid dates');
384 - return false;
385 - }
415 + // return the linear list
416 + return [
417 + $hcheckin,
418 + $mcheckin,
419 + $hcheckout,
420 + $mcheckout,
421 + ];
422 + }
386 423
387 - // make sure we have the time for check-in and check-out
388 - if (!$this->get('checkin_h') || !$this->get('checkout_h')) {
389 - // make sure to set the times (hours/minutes) for check-in and check-out
390 - $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 + }
391 437
392 - // make sure check-in and check-out timestamps have been set to a proper time
393 - $from_info = getdate($this->get('checkin'));
394 - $to_info = getdate($this->get('checkout'));
395 - if ((int) $from_info['hour'] != (int) $this->get('checkin_h')) {
396 - $this->set('checkin', mktime((int) $this->get('checkin_h'), (int) $this->get('checkin_m'), 0, $from_info['mon'], $from_info['mday'], $from_info['year']));
397 - }
398 - if ((int) $to_info['hour'] != (int) $this->get('checkout_h')) {
399 - $this->set('checkout', mktime((int) $this->get('checkout_h'), (int) $this->get('checkout_m'), 0, $to_info['mon'], $to_info['mday'], $to_info['year']));
400 - }
401 - }
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 + }
402 448
403 - // number of nights of stay
404 - if (!$this->get('nights')) {
405 - $this->set('nights', $av_helper->countNightsOfStay($this->get('checkin'), $this->get('checkout')));
406 - }
449 + return $special_requests;
450 + }
407 451
408 - // fetch and apply turnover time before doing anything else
409 - $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 + }
410 470
411 - // if rate plan selected, get the tariff ID
412 - $this->loadTariffID();
471 + if ($prevBookingRegistry) {
472 + // set booking registry snapshot prior to update
473 + $this->prevBookingRegistry = $prevBookingRegistry;
474 + }
413 475
414 - // get pool of rooms involved
415 - $rooms_pool = $this->getRoomsPool();
416 - if (!$rooms_pool) {
417 - if ($this->getError() === false) {
418 - // set generic error if not set already
419 - $this->setError('No rooms involved in the reservation');
420 - }
421 - return false;
422 - }
476 + // availability helper
477 + $av_helper = VikBooking::getAvailabilityInstance(true);
423 478
424 - // check if the room is available
425 - $room_available = $this->isRoomAvailable();
426 - if (!$this->get('force_booking', 0) && !$this->get('set_closed', 0) && !$room_available) {
427 - // no forcing, no closure and room fully booked results into an error message
428 - $this->setError(JText::translate('VBBOOKNOTMADE'));
429 - return false;
430 - }
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 + }
431 485
432 - // detect if we are forcing the reservation
433 - $this->detectForcedReason($room_available);
486 + if ($this->get('checkin') >= $this->get('checkout')) {
487 + $this->setError('Invalid dates');
488 + return false;
489 + }
434 490
435 - // store the customer information
436 - $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();
437 495
438 - // calculate total amount and total tax
439 - $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');
440 501
441 - // store booking and room-booking records
442 - if (!$this->storeReservationRecords($rooms_pool)) {
443 - if ($this->getError() === false) {
444 - // set generic error if not set already
445 - $this->setError('Could not create the reservation');
446 - }
447 - return false;
448 - }
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 + }
449 509
450 - return true;
451 - }
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 + }
452 519
453 - /**
454 - * Searches for bookings according to specified filters.
455 - *
456 - * @return array
457 - *
458 - * @since 1.16.10 (J) - 1.6.10 (WP)
459 - * @since 1.18.3 (J) - 1.8.3 (WP) added support for "ota_id" and "channel" filters.
460 - */
461 - public function search()
462 - {
463 - $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 + }
464 545
465 - $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 + }
466 550
467 - $filters = $this->getFilters();
551 + // fetch and apply turnover time before doing anything else
552 + $this->applyTurnover();
468 553
469 - if (!$filters) {
470 - $this->setError('Missing filters to search for a booking.');
471 - return [];
472 - }
554 + return true;
555 + }
473 556
474 - $q = $dbo->getQuery(true)
475 - ->select($dbo->qn('o') . '.*')
476 - ->select([
477 - $dbo->qn('c.first_name', 'customer_first_name'),
478 - $dbo->qn('c.last_name', 'customer_last_name'),
479 - ])
480 - ->from($dbo->qn('#__vikbooking_orders', 'o'))
481 - ->leftJoin($dbo->qn('#__vikbooking_customers_orders', 'co') . ' ON ' . $dbo->qn('co.idorder') . ' = ' . $dbo->qn('o.id'))
482 - ->leftJoin($dbo->qn('#__vikbooking_customers', 'c') . ' ON ' . $dbo->qn('c.id') . ' = ' . $dbo->qn('co.idcustomer'))
483 - ->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 + }
484 569
485 - if (($filters['booking_id'] ?? null)) {
486 - $q->andWhere([
487 - $dbo->qn('o.id') . ' = ' . $dbo->q($filters['booking_id']),
488 - $dbo->qn('o.idorderota') . ' = ' . $dbo->q($filters['booking_id']),
489 - ], $glue = 'OR');
490 - }
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 + }
491 578
492 - if (($filters['ota_id'] ?? null)) {
493 - $q->where($dbo->qn('o.idorderota') . ' = ' . $dbo->q($filters['ota_id']));
494 - if (($filters['channel'] ?? null)) {
495 - $q->where($dbo->qn('o.channel') . ' LIKE ' . (strpos($filters['channel'], '%') !== false ? $dbo->q($filters['channel']) : $dbo->q('%' . $filters['channel'] . '%')));
496 - } else {
497 - $q->where($dbo->qn('o.channel') . ' IS NOT NULL');
498 - }
499 - }
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 + }
500 588
501 - if (($filters['status'] ?? null)) {
502 - $q->where($dbo->qn('o.status') . ' = ' . $dbo->q($filters['status']));
503 - }
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 + }
504 598
505 - if (($filters['exclude_closures'] ?? false)) {
506 - $q->where($dbo->qn('o.closure') . ' = 0');
507 - }
599 + // detect if we are forcing the reservation
600 + $this->detectForcedReason($rooms_available);
508 601
509 - if (($filters['exclude_expired'] ?? false)) {
510 - // take only active reservations with a check-out date in the future
511 - $today_dt = JFactory::getDate('today', new DateTimeZone(date_default_timezone_get()));
512 - $q->where($dbo->qn('o.checkout') . ' >= ' . $dbo->q($today_dt->format('U', true)));
513 - }
602 + // store the customer information
603 + $this->storeCustomer();
514 604
515 - if (($filters['email'] ?? null)) {
516 - $q->where($dbo->qn('o.custmail') . ' = ' . $dbo->q($filters['email']));
517 - }
605 + // calculate total amount and total tax
606 + $this->calculateTotal();
518 607
519 - if (($filters['phone'] ?? null)) {
520 - $q->where(sprintf('REPLACE(%s, \' \', \'\') LIKE REPLACE(%s, \' \', \'\')',
521 - $dbo->qn('o.phone'),
522 - $dbo->q('%' . $filters['phone'])
523 - ));
524 - }
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 + }
525 616
526 - if (($filters['date_range']['type'] ?? null) && (($filters['date_range']['start'] ?? null) || ($filters['date_range']['end'] ?? null))) {
527 - // search by date range
528 - $from_dt = JFactory::getDate(($filters['date_range']['start'] ?? $filters['date_range']['end']));
529 - $from_dt->modify('00:00:00');
530 - $to_dt = JFactory::getDate(($filters['date_range']['end'] ?? $filters['date_range']['start']));
531 - $to_dt->modify('23:59:59');
617 + return true;
618 + }
532 619
533 - // check the type of date
534 - if ($filters['date_range']['type'] == 'stay') {
535 - // find intersections of stay dates
536 - $q->andWhere([
537 - '(' . $dbo->qn('o.checkin') . ' <= ' . $dbo->q($from_dt->format('U')) . ' AND ' . $dbo->qn('o.checkout') . ' >= ' . $dbo->q($to_dt->format('U')) . ')',
538 - '(' . $dbo->qn('o.checkin') . ' >= ' . $dbo->q($from_dt->format('U')) . ' AND ' . $dbo->qn('o.checkout') . ' <= ' . $dbo->q($to_dt->format('U')) . ')',
539 - '(' . $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')) . ')',
540 - '(' . $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')) . ')',
541 - ], $glue = 'OR');
542 - } else {
543 - $column = $dbo->qn('o.checkin');
544 - if ($filters['date_range']['type'] == 'checkout') {
545 - $column = $dbo->qn('o.checkout');
546 - } elseif ($filters['date_range']['type'] == 'creation') {
547 - $column = $dbo->qn('o.ts');
548 - }
549 - $q->where($column . ' >= ' . $dbo->q($from_dt->format('U')));
550 - $q->where($column . ' <= ' . $dbo->q($to_dt->format('U')));
551 - }
552 - } else {
553 - // check for single date filters
554 - if (($filters['creation_date'] ?? null)) {
555 - // dates are expected to be in military format
556 - $creation = JFactory::getDate($filters['creation_date']);
557 - $creation->modify('00:00:00');
558 - $q->where($dbo->qn('o.ts') . ' >= ' . $dbo->q($creation->format('U')));
559 - $creation->modify('23:59:59');
560 - $q->where($dbo->qn('o.ts') . ' <= ' . $dbo->q($creation->format('U')));
561 - }
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);
562 633
563 - if (($filters['checkin_date'] ?? null) && !($filters['checkout_date'] ?? null)) {
564 - // dates are expected to be in military format
565 - $checkin = JFactory::getDate($filters['checkin_date']);
566 - $checkin->modify('00:00:00');
567 - $q->where($dbo->qn('o.checkin') . ' >= ' . $dbo->q($checkin->format('U')));
568 - $checkin->modify('23:59:59');
569 - $q->where($dbo->qn('o.checkin') . ' <= ' . $dbo->q($checkin->format('U')));
570 - }
634 + if (!$bookingId) {
635 + $this->setError('Missing booking ID to update.');
636 + return false;
637 + }
571 638
572 - if (($filters['checkout_date'] ?? null) && !($filters['checkin_date'] ?? null)) {
573 - // dates are expected to be in military format
574 - $checkout = JFactory::getDate($filters['checkout_date']);
575 - $checkout->modify('00:00:00');
576 - $q->where($dbo->qn('o.checkout') . ' >= ' . $dbo->q($checkout->format('U')));
577 - $checkout->modify('23:59:59');
578 - $q->where($dbo->qn('o.checkout') . ' <= ' . $dbo->q($checkout->format('U')));
579 - }
639 + /**
640 + * Run preflight to ensure data integrity.
641 + */
642 + if (!$this->preflight($bookingId)) {
643 + return false;
644 + }
580 645
581 - if (($filters['checkin_date'] ?? null) && ($filters['checkout_date'] ?? null)) {
582 - // range of dates (dates are expected to be in military format)
583 - $checkin = JFactory::getDate($filters['checkin_date']);
584 - $checkin->modify('00:00:00');
585 - $checkout = JFactory::getDate($filters['checkout_date']);
586 - $checkout->modify('23:59:59');
587 - $q->andWhere([
588 - $dbo->qn('o.checkin') . ' BETWEEN ' . $dbo->q($checkin->format('U')) . ' AND ' . $dbo->q($checkout->format('U')),
589 - $dbo->qn('o.checkout') . ' BETWEEN ' . $dbo->q($checkin->format('U')) . ' AND ' . $dbo->q($checkout->format('U')),
590 - ], $glue = 'OR');
591 - }
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 + }
592 655
593 - if (($filters['stay_date'] ?? null)) {
594 - // dates are expected to be in military format
595 - $staydt = JFactory::getDate($filters['stay_date']);
596 - $staydt->modify('23:59:59');
597 - $q->where($dbo->qn('o.checkin') . ' < ' . $dbo->q($staydt->format('U')));
598 - $q->where($dbo->qn('o.checkout') . ' > ' . $dbo->q($staydt->format('U')));
599 - }
600 - }
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 + }
601 670
602 - if (($filters['customer_name'] ?? null)) {
603 - $q->where('CONCAT_WS(\' \', ' . $dbo->qn('c.first_name') . ', ' . $dbo->qn('c.last_name') . ') LIKE ' . $dbo->q('%' . $filters['customer_name'] . '%'));
604 - }
671 + // detect if we are forcing the reservation
672 + $this->detectForcedReason($rooms_available);
605 673
606 - if (($filters['confirmation_number'] ?? null)) {
607 - $q->where($dbo->qn('o.confirmnumber') . ' = ' . $dbo->q($filters['confirmation_number']));
608 - }
674 + // store or update the customer information
675 + $this->storeCustomer();
609 676
610 - if (($filters['room_name'] ?? null)) {
611 - // find the room involved from the given name
612 - $room_record = VikBooking::getAvailabilityInstance()->getRoomByName($filters['room_name']);
613 - if ($room_record) {
614 - $q->leftJoin($dbo->qn('#__vikbooking_ordersrooms', 'or') . ' ON ' . $dbo->qn('or.idorder') . ' = ' . $dbo->qn('o.id'));
615 - $q->where($dbo->qn('or.idroom') . ' = ' . (int) $room_record['id']);
616 - }
617 - }
677 + // calculate total amount and total tax
678 + $this->calculateTotal();
618 679
619 - /**
620 - * It is now possible to use a custom ordering.
621 - *
622 - * @since 1.17.1 (J) - 1.7.1 (WP)
623 - */
624 - switch ($filters['ordering'] ?? 'id') {
625 - case 'creation': $ordering = 'o.ts'; break;
626 - case 'checkin': $ordering = 'o.checkin'; break;
627 - case 'checkout': $ordering = 'o.checkout'; break;
628 - default: $ordering = 'o.id';
629 - }
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 + }
630 688
631 - $q->order($dbo->qn($ordering) . ' ' . (strcasecmp($filters['direction'] ?? 'desc', 'desc') ? 'ASC' : 'DESC'));
689 + return true;
690 + }
632 691
633 - $dbo->setQuery($q, 0, ($filters['max_bookings'] ?? 0));
634 - $rows = $dbo->loadAssocList();
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();
635 703
636 - $this->totalBookings = count($rows);
704 + $this->totalBookings = 0;
637 705
638 - /**
639 - * Calculate the total number of matching records.
640 - *
641 - * @since 1.17.1 (J) - 1.7.1 (WP)
642 - */
643 - if ($this->totalBookings && $this->totalBookings == ($filters['max_bookings'] ?? 0)) {
644 - // set up the query used to count the matching records
645 - $dbo->setQuery($q->clear('select')->clear('offset')->clear('limit')->select('COUNT(1)'));
646 - $this->totalBookings = (int) $dbo->loadResult();
647 - }
706 + $filters = $this->getFilters();
648 707
649 - return $rows;
650 - }
708 + if (!$filters) {
709 + $this->setError('Missing filters to search for a booking.');
710 + return [];
711 + }
651 712
652 - /**
653 - * Returns the total number of bookings matching the last search query made.
654 - *
655 - * @return int
656 - *
657 - * @since 1.17.1 (J) - 1.7.1 (WP)
658 - */
659 - public function getTotBookingsFound()
660 - {
661 - return $this->totalBookings;
662 - }
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);
663 723
664 - /**
665 - * Updates the total amount paid and the amount of (OTA) compensation. To be used for
666 - * OTA reservations only, previously validated. Runs often after an OTA notification.
667 - *
668 - * @param array $options Associative list of booking values to update.
669 - * @param array $booking Optional associative booking record to update.
670 - *
671 - * @return bool
672 - *
673 - * @since 1.18.3 (J) - 1.8.3 (WP)
674 - */
675 - public function updatePayoutCompensation(array $options, array $booking = [])
676 - {
677 - $dbo = JFactory::getDbo();
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 + }
678 730
679 - if ($booking) {
680 - // update the internal reference
681 - $this->setBooking($booking);
682 - } else {
683 - // get the internal booking record
684 - $booking = $this->getBooking();
685 - }
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 + }
686 739
687 - if (empty($booking['id'])) {
688 - // no booking information available
689 - return false;
690 - }
740 + if (($filters['status'] ?? null)) {
741 + $q->where($dbo->qn('o.status') . ' = ' . $dbo->q($filters['status']));
742 + }
691 743
692 - // build booking record values to update
693 - $booking_record = new stdClass;
694 - $booking_record->id = $booking['id'];
744 + if (($filters['exclude_closures'] ?? false)) {
745 + $q->where($dbo->qn('o.closure') . ' = 0');
746 + }
695 747
696 - // access the booking history object
697 - $history_obj = VikBooking::getBookingHistoryInstance($booking['id']);
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 + }
698 753
699 - // flag/counter that indicates if any booking record value was updated
700 - $booking_values_counter = 0;
754 + if (($filters['email'] ?? null)) {
755 + $q->where($dbo->qn('o.custmail') . ' = ' . $dbo->q($filters['email']));
756 + }
701 757
702 - // check if a payout should be set
703 - if ($options['payout'] ?? null) {
704 - // update the booking total amount paid and register a payout received event
705 - $booking_record->totpaid = (float) $options['payout'];
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 + }
706 773
707 - // update cached booking information for history
708 - $booking['totpaid'] = (float) $options['payout'];
709 - $history_obj->setBookingInfo($booking);
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');
710 780
711 - // the payout received event type
712 - $ev_type = 'PO';
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 + }
713 810
714 - // load current history
715 - $history_rows = array_reverse($history_obj->loadHistory());
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 + }
716 819
717 - // check if there is a valid event with an amount paid greater than zero
718 - $prev_paid = 0;
719 - foreach ($history_rows as $hevent) {
720 - if ($hevent['totpaid'] < 1 || $hevent['totpaid'] == $options['payout'] || $hevent['type'] == $ev_type) {
721 - // skip history records with no amount paid, amount paid equal to payout, or payout events
722 - continue;
723 - }
724 - // amount paid found before payout notification
725 - $prev_paid = $hevent['totpaid'];
726 - break;
727 - }
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 + }
728 828
729 - // sum any previously paid amount
730 - $booking_record->totpaid += $prev_paid;
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 + }
731 840
732 - // update booking record in VBO
733 - if ($dbo->updateObject('#__vikbooking_orders', $booking_record, 'id')) {
734 - // increase counter
735 - $booking_values_counter++;
736 - }
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 + }
737 849
738 - // build event description
739 - $ev_descr = preg_replace('/^[^_]*_/', '', (string) ($booking['channel'] ?? '')) . ': ' . $options['payout'];
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 + }
740 853
741 - // set event extra data
742 - $history_obj->setExtraData([
743 - // this is the total amount paid up until now
744 - 'payout_total' => (float) $options['payout'],
745 - ]);
854 + if (($filters['confirmation_number'] ?? null)) {
855 + $q->where($dbo->qn('o.confirmnumber') . ' = ' . $dbo->q($filters['confirmation_number']));
856 + }
746 857
747 - // silence the notification center
748 - $history_obj->setSilentNotificationCenter(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 + }
749 866
750 - // store event history log
751 - $history_obj->store($ev_type, $ev_descr);
752 - }
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 + }
753 878
754 - // check if the commission (compensation) amount should be set
755 - if ($options['compensation'] ?? null) {
756 - // update the total commissions amount
757 - $booking_record->cmms = (float) $options['compensation'];
879 + $q->order($dbo->qn($ordering) . ' ' . (strcasecmp($filters['direction'] ?? 'desc', 'desc') ? 'ASC' : 'DESC'));
758 880
759 - // update cached booking information for history
760 - $booking['cmms'] = (float) $options['compensation'];
761 - $history_obj->setBookingInfo($booking);
881 + $dbo->setQuery($q, 0, ($filters['max_bookings'] ?? 0));
882 + $rows = $dbo->loadAssocList();
762 883
763 - // update booking record in VBO
764 - if ($dbo->updateObject('#__vikbooking_orders', $booking_record, 'id')) {
765 - // increase counter
766 - $booking_values_counter++;
767 - }
884 + $this->totalBookings = count($rows);
768 885
769 - // set a generic CM event type
770 - $ev_type = 'CM';
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 + }
771 896
772 - // build event description
773 - $ev_descr = 'OTA Commissions: ' . $options['compensation'];
897 + return $rows;
898 + }
774 899
775 - // set event extra data
776 - $history_obj->setExtraData([
777 - 'cmms' => (float) $options['compensation'],
778 - ]);
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 + }
779 911
780 - // silence the notification center
781 - $history_obj->setSilentNotificationCenter(true);
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();
782 926
783 - // store event history log
784 - $history_obj->store($ev_type, $ev_descr);
785 - }
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 + }
786 934
787 - return (bool) $booking_values_counter;
788 - }
935 + if (empty($booking['id'])) {
936 + // no booking information available
937 + return false;
938 + }
789 939
790 - /**
791 - * Modifies the requested booking ID according to the provided options.
792 - * This method does not support all rate plan options like for the creation of a
793 - * new booking. This is a method for making quick updates concerning a room switch,
794 - * a change of stay dates, new booking total amount, guests, add extra services etc..
795 - *
796 - * @param array $options List of details to perform the modification.
797 - *
798 - * @return bool
799 - *
800 - * @since 1.16.10 (J) - 1.6.10 (WP)
801 - */
802 - public function modify(array $options)
803 - {
804 - $dbo = JFactory::getDbo();
940 + // build booking record values to update
941 + $booking_record = new stdClass;
942 + $booking_record->id = $booking['id'];
805 943
806 - // access the previous booking details
807 - $prev_booking = $this->getBooking();
944 + // access the booking history object
945 + $history_obj = VikBooking::getBookingHistoryInstance($booking['id']);
808 946
809 - // access the current rooms booked
810 - $roomBooking = $this->getRoomBooking();
947 + // flag/counter that indicates if any booking record value was updated
948 + $booking_values_counter = 0;
811 949
812 - // gather modification options
813 - $booking_id = $options['booking_id'] ?? $prev_booking['id'] ?? 0;
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'];
814 954
815 - if (!$booking_id) {
816 - $this->setError('Missing booking ID.');
817 - return false;
818 - }
955 + // update cached booking information for history
956 + $booking['totpaid'] = (float) $options['payout'];
957 + $history_obj->setBookingInfo($booking);
819 958
820 - if (!$prev_booking) {
821 - // load current booking record if not injected
822 - $prev_booking = VikBooking::getBookingInfoFromID($booking_id);
823 - if (!$prev_booking) {
824 - $this->setError('Booking not found.');
825 - return false;
826 - }
827 - }
959 + // the payout received event type
960 + $ev_type = 'PO';
828 961
829 - if (!$roomBooking) {
830 - // load current rooms booked
831 - $roomBooking = VikBooking::loadOrdersRoomsData($booking_id);
832 - }
962 + // load current history
963 + $history_rows = array_reverse($history_obj->loadHistory());
833 964
834 - // do not touch this array property because it's used by VCM
835 - $prev_booking['rooms_info'] = $roomBooking;
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 + }
836 976
837 - // list of operations to trigger/perform
838 - $trigger_operations = [];
977 + // sum any previously paid amount
978 + $booking_record->totpaid += $prev_paid;
839 979
840 - // list of history description rows
841 - $history_descr_rows = [];
980 + // update booking record in VBO
981 + if ($dbo->updateObject('#__vikbooking_orders', $booking_record, 'id')) {
982 + // increase counter
983 + $booking_values_counter++;
984 + }
842 985
843 - // access availability helper
844 - $av_helper = VikBooking::getAvailabilityInstance(true);
986 + // build event description
987 + $ev_descr = preg_replace('/^[^_]*_/', '', (string) ($booking['channel'] ?? '')) . ': ' . $options['payout'];
845 988
846 - // calculate the new stay dates, if different
847 - $diff_stay_dates = false;
848 - $set_checkin = date('Y-m-d', $prev_booking['checkin']);
849 - $set_checkout = date('Y-m-d', $prev_booking['checkout']);
850 - if (($options['checkin'] ?? null)) {
851 - // date is expected in military format
852 - $diff_stay_dates = $diff_stay_dates || ($options['checkin'] != $set_checkin);
853 - $set_checkin = $options['checkin'];
854 - }
855 - if (($options['checkout'] ?? null)) {
856 - // date is expected in military format
857 - $diff_stay_dates = $diff_stay_dates || ($options['checkout'] != $set_checkout);
858 - $set_checkout = $options['checkout'];
859 - }
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 + ]);
860 994
861 - // ensure the stay dates are valid
862 - if (JFactory::getDate($set_checkin) >= JFactory::getDate($set_checkout)) {
863 - $this->setError('Invalid stay dates provided.');
864 - return false;
865 - }
995 + // silence the notification center
996 + $history_obj->setSilentNotificationCenter(true);
866 997
867 - // ensure we are not changing dates for a split-stay reservation
868 - if ($diff_stay_dates && !empty($prev_booking['split_stay'])) {
869 - // we receive the stay dates at booking record, so we cannot proceed with the update
870 - $this->setError('Cannot modify the stay dates for a split-stay reservation. Please do it manually.');
871 - return false;
872 - }
998 + // store event history log
999 + $history_obj->store($ev_type, $ev_descr);
1000 + }
873 1001
874 - // set dates involved
875 - $av_helper->setStayDates($set_checkin, $set_checkout);
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'];
876 1006
877 - // count new nights of stay
878 - $set_nights = $av_helper->countNightsOfStay();
1007 + // update cached booking information for history
1008 + $booking['cmms'] = (float) $options['compensation'];
1009 + $history_obj->setBookingInfo($booking);
879 1010
880 - // gather stay timestamps
881 - list($set_checkin_ts, $set_checkout_ts) = $av_helper->getStayDates(true);
1011 + // update booking record in VBO
1012 + if ($dbo->updateObject('#__vikbooking_orders', $booking_record, 'id')) {
1013 + // increase counter
1014 + $booking_values_counter++;
1015 + }
882 1016
883 - // load the current busy record IDs before any modification, if any
884 - $dbo->setQuery(
885 - $dbo->getQuery(true)
886 - ->select('*')
887 - ->from($dbo->qn('#__vikbooking_ordersbusy'))
888 - ->where($dbo->qn('idorder') . ' = ' . (int) $booking_id)
889 - );
890 - $busy_ids = array_column($dbo->loadAssocList(), 'idbusy');
1017 + // set a generic CM event type
1018 + $ev_type = 'CM';
891 1019
892 - // first off, check if any room switch was requested (recommended one switch at most)
893 - $switching_details = [];
894 - if ($options['switch_rooms'] ?? []) {
895 - // get all room IDs for the switch that were not booked already
896 - $booked_rooms = array_column($roomBooking, 'idroom');
897 - $new_missing_rooms = array_values(array_diff((array) $options['switch_rooms'], $booked_rooms));
1020 + // build event description
1021 + $ev_descr = 'OTA Commissions: ' . $options['compensation'];
898 1022
899 - // scan all rooms requested for the switch that were not booked already
900 - foreach ($new_missing_rooms as $index => $switch_room_id) {
901 - if (!isset($roomBooking[$index])) {
902 - // adding more rooms is not supported
903 - break;
904 - }
905 - // ensure the room switch is allowed (room should be available on the new dates)
906 - $switched_room_info = VikBooking::getRoomInfo($switch_room_id, ['id', 'name', 'units']);
907 - if (!$switched_room_info) {
908 - $this->setError('The requested room could not be found for the switch.');
909 - return false;
910 - }
911 - if (!VikBooking::roomBookable($switch_room_id, 1, $set_checkin_ts, $set_checkout_ts, $busy_ids)) {
912 - // abort by setting a descriptive error message
913 - $this->setError(sprintf(
914 - 'The room %s is not available from %s to %s, and so the room switch cannot be made.',
915 - $switched_room_info['name'] ?? '',
916 - $set_checkin,
917 - $set_checkout
918 - ));
919 - return false;
920 - }
921 - }
1023 + // set event extra data
1024 + $history_obj->setExtraData([
1025 + 'cmms' => (float) $options['compensation'],
1026 + ]);
922 1027
923 - // scan again all rooms to be switched once we know they are available
924 - foreach ($new_missing_rooms as $index => $switch_room_id) {
925 - if (!isset($roomBooking[$index])) {
926 - // adding more rooms is not supported
927 - break;
928 - }
1028 + // silence the notification center
1029 + $history_obj->setSilentNotificationCenter(true);
929 1030
930 - // update room-booking record by switching room ID
931 - $q = $dbo->getQuery(true)
932 - ->update($dbo->qn('#__vikbooking_ordersrooms'))
933 - ->set($dbo->qn('idroom') . ' = ' . (int) $switch_room_id)
934 - ->where($dbo->qn('idorder') . ' = ' . (int) $booking_id)
935 - ->where($dbo->qn('idroom') . ' = ' . (int) ($roomBooking[$index]['idroom'] ?? 0));
936 - $dbo->setQuery($q, 0, 1);
937 - $dbo->execute();
1031 + // store event history log
1032 + $history_obj->store($ev_type, $ev_descr);
1033 + }
938 1034
939 - if ($busy_ids) {
940 - // update busy records with new stay dates just for the switched room
941 - $q = $dbo->getQuery(true)
942 - ->update($dbo->qn('#__vikbooking_busy'))
943 - ->set($dbo->qn('idroom') . ' = ' . (int) $switch_room_id)
944 - ->set($dbo->qn('checkin') . ' = ' . $dbo->q($set_checkin_ts))
945 - ->set($dbo->qn('checkout') . ' = ' . $dbo->q($set_checkout_ts))
946 - ->set($dbo->qn('realback') . ' = ' . $dbo->q($set_checkout_ts + (VikBooking::getHoursRoomAvail() * 3600)))
947 - ->where($dbo->qn('id') . ' IN (' . implode(', ', array_map('intval', $busy_ids)) . ')')
948 - ->where($dbo->qn('idroom') . ' = ' . (int) ($roomBooking[$index]['idroom'] ?? 0));
949 - $dbo->setQuery($q, 0, 1);
950 - $dbo->execute();
1035 + return (bool) $booking_values_counter;
1036 + }
951 1037
952 - // register room switching details
953 - $switching_details[$index] = $switch_room_id;
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();
954 1053
955 - // register CM sync operation
956 - $trigger_operations[] = 'vcm_sync';
957 - }
1054 + if (!$bookingId) {
1055 + // get the internal booking record
1056 + $booking = $this->getBooking();
1057 + $bookingId = $booking['id'] ?? null;
1058 + }
958 1059
959 - // register history description row
960 - $switched_room_info = VikBooking::getRoomInfo($switch_room_id, ['id', 'name', 'units']);
961 - $prev_room_info = VikBooking::getRoomInfo($roomBooking[$index]['idroom'] ?? 0, ['id', 'name', 'units']);
962 - $history_descr_rows[] = sprintf('%s switched with %s.', $prev_room_info['name'] ?? '', $switched_room_info['name'] ?? '');
963 - }
964 - }
1060 + if (empty($bookingId)) {
1061 + throw new Exception('Missing booking ID.', 400);
1062 + }
965 1063
966 - // start query builder for booking record
967 - $bookingQ = $dbo->getQuery(true)
968 - ->update($dbo->qn('#__vikbooking_orders'))
969 - ->where($dbo->qn('id') . ' = ' . (int) $booking_id);
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();
970 1072
971 - // modify stay dates, if requested
972 - if ($diff_stay_dates) {
973 - // ensure all rooms are bookable on the new stay dates
974 - if ($prev_booking['status'] == 'confirmed') {
975 - foreach ($roomBooking as $kor => $or) {
976 - if ($switching_details[$kor] ?? null) {
977 - // this room index was switched with another room, hence we know it was available
978 - continue;
979 - }
980 - if (!VikBooking::roomBookable($or['idroom'], 1, $set_checkin_ts, $set_checkout_ts, $busy_ids)) {
981 - // abort
982 - $abort_room_info = VikBooking::getRoomInfo($or['idroom'], ['id', 'name', 'units']);
983 - $this->setError(sprintf(
984 - 'The room %s is not available from %s to %s, and so the stay dates cannot be modified.',
985 - $abort_room_info['name'] ?? '',
986 - $set_checkin,
987 - $set_checkout
988 - ));
989 - return false;
990 - }
991 - }
992 - }
1073 + return (bool) $dbo->getAffectedRows();
1074 + }
993 1075
994 - // set booking values to update
995 - $bookingQ->set($dbo->qn('checkin') . ' = ' . $dbo->q($set_checkin_ts));
996 - $bookingQ->set($dbo->qn('checkout') . ' = ' . $dbo->q($set_checkout_ts));
997 - $bookingQ->set($dbo->qn('days') . ' = ' . $dbo->q($set_nights));
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();
998 1091
999 - // update busy records, if any (reservation status could be confirmed)
1000 - if ($busy_ids) {
1001 - // update busy records with new stay dates for all rooms
1002 - $dbo->setQuery(
1003 - $dbo->getQuery(true)
1004 - ->update($dbo->qn('#__vikbooking_busy'))
1005 - ->set($dbo->qn('checkin') . ' = ' . $dbo->q($set_checkin_ts))
1006 - ->set($dbo->qn('checkout') . ' = ' . $dbo->q($set_checkout_ts))
1007 - ->set($dbo->qn('realback') . ' = ' . $dbo->q($set_checkout_ts + (VikBooking::getHoursRoomAvail() * 3600)))
1008 - ->where($dbo->qn('id') . ' IN (' . implode(', ', array_map('intval', $busy_ids)) . ')')
1009 - );
1010 - $dbo->execute();
1092 + if (!$bookingId) {
1093 + // get the internal booking record
1094 + $booking = $this->getBooking();
1095 + $bookingId = $booking['id'] ?? null;
1096 + }
1011 1097
1012 - // register CM sync operation
1013 - $trigger_operations[] = 'vcm_sync';
1014 - }
1098 + if (empty($bookingId)) {
1099 + throw new Exception('Missing booking ID.', 400);
1100 + }
1015 1101
1016 - // register operation to trigger the shared calendars
1017 - $trigger_operations[] = 'shared_calendars';
1018 - }
1102 + $knownStatuses = [-1, 0, 1, 2];
1019 1103
1020 - // update number of guests, if requested
1021 - if (($options['guests']['adults'] ?? null) || ($options['guests']['children'] ?? null)) {
1022 - // update the requested number of guests ONLY on the first room booked
1023 - $q = $dbo->getQuery(true)
1024 - ->update($dbo->qn('#__vikbooking_ordersrooms'))
1025 - ->set($dbo->qn('adults') . ' = ' . (int) ($options['guests']['adults'] ?? $roomBooking[0]['adults']))
1026 - ->set($dbo->qn('children') . ' = ' . (int) ($options['guests']['children'] ?? $roomBooking[0]['children']))
1027 - ->where($dbo->qn('idorder') . ' = ' . (int) $booking_id);
1028 - $dbo->setQuery($q, 0, 1);
1029 - $dbo->execute();
1104 + if (!in_array($status, $knownStatuses, true)) {
1105 + throw new InvalidArgumentException('Invalid registration status.', 400);
1106 + }
1030 1107
1031 - // register history description row
1032 - $history_descr_rows[] = sprintf(
1033 - 'New adults %d, new children %d.',
1034 - (int) ($options['guests']['adults'] ?? $roomBooking[0]['adults']),
1035 - (int) ($options['guests']['children'] ?? $roomBooking[0]['children'])
1036 - );
1037 - }
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();
1038 1116
1039 - // check if extra services should be added and calculate the booking cost difference
1040 - $new_extras_cost = 0;
1041 - if (is_array(($options['add_extra_services'] ?? null))) {
1042 - $current_extras = !empty($roomBooking[0]['extracosts']) ? json_decode($roomBooking[0]['extracosts'], true) : [];
1043 - $current_extras = is_array($current_extras) ? $current_extras : [];
1044 - $new_extras = [];
1045 - foreach ($options['add_extra_services'] as $extras) {
1046 - if (!is_array($extras) || (!isset($extras['name']) && !isset($extras['cost']))) {
1047 - // invalid extra service structure
1048 - continue;
1049 - }
1117 + return (bool) $dbo->getAffectedRows();
1118 + }
1050 1119
1051 - // build new extra service
1052 - $new_extra = [
1053 - 'name' => (string) ($extras['name'] ?? 'Custom Extra'),
1054 - 'cost' => (float) ($extras['cost'] ?? 0),
1055 - 'idtax' => null,
1056 - ];
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();
1057 1136
1058 - // push custom extra service
1059 - $current_extras[] = $new_extra;
1137 + if (!$bookingId) {
1138 + // get the internal booking record
1139 + $booking = $this->getBooking();
1140 + $bookingId = $booking['id'] ?? null;
1141 + }
1060 1142
1061 - // push the custom extra service in the new list
1062 - $new_extras[] = $new_extra;
1063 - }
1143 + if (empty($bookingId)) {
1144 + throw new Exception('Missing booking ID.', 400);
1145 + }
1064 1146
1065 - if ($new_extras) {
1066 - // update the extra services ONLY on the first room booked
1067 - $q = $dbo->getQuery(true)
1068 - ->update($dbo->qn('#__vikbooking_ordersrooms'))
1069 - ->set($dbo->qn('extracosts') . ' = ' . $dbo->q(json_encode($current_extras)))
1070 - ->where($dbo->qn('idorder') . ' = ' . (int) $booking_id);
1071 - $dbo->setQuery($q, 0, 1);
1072 - $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();
1073 1154
1074 - // register history description row
1075 - $history_descr_rows[] = sprintf(
1076 - 'New extras: %s.',
1077 - implode(', ', array_column($new_extras, 'name'))
1078 - );
1155 + return (bool) $dbo->getAffectedRows();
1156 + }
1079 1157
1080 - // check if we need to increase the booking total amount
1081 - $new_extras_cost = array_sum(array_column($new_extras, 'cost'));
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();
1082 1174
1083 - if ($new_extras_cost > 0 && (float) ($options['cost_difference'] ?? 0) < $new_extras_cost) {
1084 - // increase the "cost difference" due to the newly added extra services
1085 - $options['cost_difference'] = ($options['cost_difference'] ?? 0) + $new_extras_cost;
1086 - }
1087 - }
1088 - }
1175 + // access the previous booking details
1176 + $prev_booking = $this->getBooking();
1089 1177
1090 - // check if the booking total amount should change
1091 - if ($options['cost_difference'] ?? null) {
1092 - // this difference should be summed to (or deducted from) the current booking total value
1093 - $bookingQ->set($dbo->qn('total') . ' = ' . ($prev_booking['total'] + (float) $options['cost_difference']));
1178 + // access the current rooms booked
1179 + $roomBooking = $this->getRoomBooking();
1094 1180
1095 - // register history description row
1096 - $history_descr_rows[] = sprintf(
1097 - 'Booking total cost difference calculated: %d.',
1098 - (float) $options['cost_difference']
1099 - );
1181 + // gather modification options
1182 + $booking_id = $options['booking_id'] ?? $prev_booking['id'] ?? 0;
1100 1183
1101 - // calculate the cost difference just for the rooms
1102 - $rooms_cost_difference = (float) $options['cost_difference'] - $new_extras_cost;
1184 + if (!$booking_id) {
1185 + $this->setError('Missing booking ID.');
1186 + return false;
1187 + }
1103 1188
1104 - if ($rooms_cost_difference) {
1105 - // this value should be summed to (or deducted from) the current room rate to have a proper calculation
1106 - $new_room_cost = null;
1107 - $room_cost_prop = null;
1108 - if (!empty($roomBooking[0]['cust_cost'])) {
1109 - $new_room_cost = $roomBooking[0]['cust_cost'] + $rooms_cost_difference;
1110 - $room_cost_prop = 'cust_cost';
1111 - } elseif (!empty($roomBooking[0]['room_cost'])) {
1112 - $new_room_cost = $roomBooking[0]['room_cost'] + $rooms_cost_difference;
1113 - $room_cost_prop = 'room_cost';
1114 - }
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 + }
1115 1197
1116 - if ($room_cost_prop) {
1117 - // we can update the room cost for the difference calculated ONLY on the first room booked
1118 - // if no room cost was found, maybe because of a tariff, we would keep just the total changed
1119 - $q = $dbo->getQuery(true)
1120 - ->update($dbo->qn('#__vikbooking_ordersrooms'))
1121 - ->set($dbo->qn($room_cost_prop) . ' = ' . $new_room_cost)
1122 - ->where($dbo->qn('idorder') . ' = ' . (int) $booking_id);
1123 - $dbo->setQuery($q, 0, 1);
1124 - $dbo->execute();
1125 - }
1126 - }
1127 - }
1198 + if (!$roomBooking) {
1199 + // load current rooms booked
1200 + $roomBooking = VikBooking::loadOrdersRoomsData($booking_id);
1201 + }
1128 1202
1129 - if ($options['extra_notes'] ?? '') {
1130 - // update administrator notes
1131 - $bookingQ->set($dbo->qn('adminnotes') . ' = ' . $dbo->q(trim($prev_booking['adminnotes'] . "\n" . $options['extra_notes'])));
1132 - }
1203 + // do not touch this array property because it's used by VCM
1204 + $prev_booking['rooms_info'] = $roomBooking;
1133 1205
1134 - if (($options['custmail'] ?? '') || ($options['customer_email'] ?? '')) {
1135 - // update guest email address at booking level
1136 - $set_cust_mail = $options['custmail'] ?? $options['customer_email'] ?? '';
1137 - if (preg_match("/^[^@]+@[^@]+\.[^@]+$/", $set_cust_mail)) {
1138 - // email pattern is safe
1139 - $bookingQ->set($dbo->qn('custmail') . ' = ' . $dbo->q(trim($set_cust_mail)));
1140 - }
1141 - }
1206 + // list of operations to trigger/perform
1207 + $trigger_operations = [];
1142 1208
1143 - // finally, update the booking record
1144 - try {
1145 - // make sure something to update was set by using the apposite getter magic method
1146 - if ($bookingQ->set) {
1147 - // some booking record values should be updated
1148 - $dbo->setQuery($bookingQ);
1149 - $dbo->execute();
1150 - }
1151 - } catch (Throwable $e) {
1152 - $this->setError($e->getMessage());
1153 - return false;
1154 - }
1209 + // list of history description rows
1210 + $history_descr_rows = [];
1155 1211
1156 - // update booking history
1157 - $history_obj = VikBooking::getBookingHistoryInstance($booking_id);
1212 + // access availability helper
1213 + $av_helper = VikBooking::getAvailabilityInstance(true);
1158 1214
1159 - $now_user = JFactory::getUser();
1160 - $caller_id = $now_user->name ? "({$now_user->name})" : '';
1161 - if ($this->getCaller()) {
1162 - $caller_id = '(' . $this->getCaller() . ')';
1163 - if ($this->getHistoryData()) {
1164 - $history_obj->setExtraData($this->getHistoryData());
1165 - }
1166 - }
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 + }
1167 1229
1168 - // update Booking History
1169 - $history_obj->store('MB', $caller_id . ($history_descr_rows ? "\n" . implode("\n", $history_descr_rows) : ''));
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 + }
1170 1235
1171 - // check for the operations to perform
1172 - if (in_array('shared_calendars', $trigger_operations)) {
1173 - // unset any previously booked room due to calendar sharing
1174 - VikBooking::cleanSharedCalendarsBusy($booking_id);
1175 - // check if some of the rooms booked have shared calendars
1176 - VikBooking::updateSharedCalendars($booking_id);
1177 - }
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 + }
1178 1242
1179 - if (in_array('vcm_sync', $trigger_operations)) {
1180 - // invoke Channel Manager
1181 - $vcm_autosync = VikBooking::vcmAutoUpdate();
1182 - if ($vcm_autosync > 0) {
1183 - $vcm_obj = VikBooking::getVcmInvoker();
1184 - $vcm_obj->setOids([$booking_id])->setSyncType('modify')->setOriginalBooking($prev_booking);
1185 - $sync_result = $vcm_obj->doSync();
1186 - if ($sync_result === false) {
1187 - // set error message
1188 - $vcm_err = $vcm_obj->getError();
1189 - $this->setError(JText::translate('VBCHANNELMANAGERRESULTKO') . (!empty($vcm_err) ? ' - ' . $vcm_err : ''));
1190 - }
1191 - } elseif (is_file(VCM_SITE_PATH . DIRECTORY_SEPARATOR . 'helpers' . DIRECTORY_SEPARATOR . 'synch.vikbooking.php')) {
1192 - // set the necessary action to invoke VCM manually
1193 - $this->setChannelManagerAction(
1194 - JText::translate('VBCHANNELMANAGERINVOKEASK') . ' ' .
1195 - '<form action="index.php?option=com_vikbooking" method="post">' .
1196 - '<input type="hidden" name="option" value="com_vikbooking"/>' .
1197 - '<input type="hidden" name="task" value="invoke_vcm"/>' .
1198 - '<input type="hidden" name="stype" value="modify"/>' .
1199 - '<input type="hidden" name="cid[]" value="' . $booking_id . '"/>' .
1200 - '<input type="hidden" name="origb" value="' . urlencode(json_encode($prev_booking)) . '"/>' .
1201 - '<button type="submit" class="btn btn-primary">' . JText::translate('VBCHANNELMANAGERSENDRQ') . '</button>' .
1202 - '</form>'
1203 - );
1204 - }
1205 - }
1243 + // set dates involved
1244 + $av_helper->setStayDates($set_checkin, $set_checkout);
1206 1245
1207 - if (($options['ota_reporting'] ?? null) && $diff_stay_dates) {
1208 - // perform the OTA reporting action, if allowed
1209 - if (class_exists('VCMOtaReporting') && VCMOtaReporting::getInstance($ord)->stayChangeAllowed()) {
1210 - // check if an OTA reporting action is needed
1211 - $ota_stay_change_data = [];
1212 - foreach ($roomBooking as $kor => $or) {
1213 - // set room data for stay change
1214 - $ota_stay_change_room = [
1215 - 'idroom' => $or['idroom'],
1216 - 'checkin' => $set_checkin,
1217 - 'checkout' => $set_checkout,
1218 - ];
1219 - if (isset($or['modified_price'])) {
1220 - $ota_stay_change_room['price'] = $or['modified_price'];
1221 - }
1222 - // push room data for stay change
1223 - $ota_stay_change_data[] = $ota_stay_change_room;
1224 - }
1246 + // count new nights of stay
1247 + $set_nights = $av_helper->countNightsOfStay();
1225 1248
1226 - // notify the OTA through Vik Channel Manager
1227 - $ota_reporting = VCMOtaReporting::getInstance();
1228 - $ota_result = $ota_reporting->notifyStayChange($ota_stay_change_data);
1229 - if (!$ota_result) {
1230 - // register error message
1231 - $this->setError($ota_reporting->getError());
1232 - }
1233 - }
1234 - }
1249 + // gather stay timestamps
1250 + list($set_checkin_ts, $set_checkout_ts) = $av_helper->getStayDates(true);
1235 1251
1236 - return true;
1237 - }
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');
1238 1260
1239 - /**
1240 - * Deletes the requested booking ID.
1241 - *
1242 - * @param array $options List of details to perform the cancellation.
1243 - *
1244 - * @return bool
1245 - *
1246 - * @since 1.16.10 (J) - 1.6.10 (WP)
1247 - */
1248 - public function delete(array $options)
1249 - {
1250 - $dbo = JFactory::getDbo();
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));
1251 1267
1252 - $booking_id = $options['booking_id'] ?? 0;
1253 - $canc_reason = $options['cancellation_reason'] ?? '';
1254 - $purge_remove = $options['purge_remove'] ?? false;
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 + }
1255 1291
1256 - $booking = VikBooking::getBookingInfoFromID($booking_id);
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 + }
1257 1298
1258 - if (!$booking) {
1259 - $this->setError('Booking not found.');
1260 - return false;
1261 - }
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();
1262 1307
1263 - if ($booking['status'] === 'cancelled' && !$purge_remove) {
1264 - $this->setError(sprintf('Booking ID %d is already cancelled.', $booking['id']));
1265 - return false;
1266 - }
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();
1267 1320
1268 - if (class_exists('VCMFeesCancellation')) {
1269 - // let VCM detect if there are any constraints for the cancellation
1270 - $canc_denied = VCMFeesCancellation::getInstance($booking, $anew = true)->isBookingConstrained();
1271 - if ($canc_denied) {
1272 - // set error message
1273 - $canc_deny_error = VCMFeesCancellation::getInstance()->getError();
1274 - $this->setError($canc_deny_error ?: 'Booking cannot be cancelled due to OTA contraints.');
1275 - return false;
1276 - }
1277 - }
1321 + // register room switching details
1322 + $switching_details[$index] = $switch_room_id;
1278 1323
1279 - // access the current user
1280 - $now_user = JFactory::getUser();
1324 + // register CM sync operation
1325 + $trigger_operations[] = 'vcm_sync';
1326 + }
1281 1327
1282 - // whether OTAs should be notified
1283 - $notify_otas = false;
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 + }
1284 1334
1285 - if ($booking['status'] != 'cancelled') {
1286 - // update status to cancelled
1287 - $q = $dbo->getQuery(true)
1288 - ->update($dbo->qn('#__vikbooking_orders'))
1289 - ->set($dbo->qn('status') . ' = ' . $dbo->q('cancelled'))
1290 - ->where($dbo->qn('id') . ' = ' . (int) $booking['id']);
1291 - if (!empty($canc_reason)) {
1292 - $set_canc_reason = (!empty($booking['adminnotes']) ? $booking['adminnotes'] . "\n" : '') . $canc_reason;
1293 - $q->set($dbo->qn('adminnotes') . ' = ' . $dbo->q($set_canc_reason));
1294 - }
1295 - $dbo->setQuery($q);
1296 - $dbo->execute();
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);
1297 1339
1298 - // delete temporarily locked records, if any
1299 - $dbo->setQuery(
1300 - $dbo->getQuery(true)
1301 - ->delete($dbo->qn('#__vikbooking_tmplock'))
1302 - ->where($dbo->qn('idorder') . ' = ' . (int) $booking['id'])
1303 - );
1304 - $dbo->execute();
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 + }
1305 1362
1306 - if ($booking['status'] == 'confirmed') {
1307 - // turn flag on
1308 - $notify_otas = true;
1309 - }
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));
1310 1367
1311 - // access history object
1312 - $history_obj = VikBooking::getBookingHistoryInstance($booking['id']);
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();
1313 1380
1314 - $caller_id = $now_user->name ? "({$now_user->name})" : '';
1315 - if ($this->getCaller()) {
1316 - $caller_id = '(' . $this->getCaller() . ')';
1317 - if ($this->getHistoryData()) {
1318 - $history_obj->setExtraData($this->getHistoryData());
1319 - }
1320 - }
1381 + // register CM sync operation
1382 + $trigger_operations[] = 'vcm_sync';
1383 + }
1321 1384
1322 - // update Booking History
1323 - $history_obj->store('CB', $caller_id);
1324 - }
1385 + // register operation to trigger the shared calendars
1386 + $trigger_operations[] = 'shared_calendars';
1387 + }
1325 1388
1326 - // always attempt to free records up
1327 - $dbo->setQuery(
1328 - $dbo->getQuery(true)
1329 - ->select('*')
1330 - ->from($dbo->qn('#__vikbooking_ordersbusy'))
1331 - ->where($dbo->qn('idorder') . ' = ' . (int) $booking['id'])
1332 - );
1333 - foreach ($dbo->loadAssocList() as $ob) {
1334 - // delete busy record
1335 - $dbo->setQuery(
1336 - $dbo->getQuery(true)
1337 - ->delete($dbo->qn('#__vikbooking_busy'))
1338 - ->where($dbo->qn('id') . ' = ' . (int) $ob['idbusy'])
1339 - );
1340 - $dbo->execute();
1341 - }
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();
1342 1399
1343 - // delete booking-busy-record relations
1344 - $dbo->setQuery(
1345 - $dbo->getQuery(true)
1346 - ->delete($dbo->qn('#__vikbooking_ordersbusy'))
1347 - ->where($dbo->qn('idorder') . ' = ' . (int) $booking['id'])
1348 - );
1349 - $dbo->execute();
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 + }
1350 1407
1351 - // check for purge removal
1352 - if ($booking['status'] === 'cancelled' && $purge_remove) {
1353 - // delete booking-customer relation
1354 - $dbo->setQuery(
1355 - $dbo->getQuery(true)
1356 - ->delete($dbo->qn('#__vikbooking_customers_orders'))
1357 - ->where($dbo->qn('idorder') . ' = ' . (int) $booking['id'])
1358 - );
1359 - $dbo->execute();
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 + }
1360 1419
1361 - // delete booking-room relations
1362 - $dbo->setQuery(
1363 - $dbo->getQuery(true)
1364 - ->delete($dbo->qn('#__vikbooking_ordersrooms'))
1365 - ->where($dbo->qn('idorder') . ' = ' . (int) $booking['id'])
1366 - );
1367 - $dbo->execute();
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 + ];
1368 1426
1369 - // delete booking-history relations
1370 - $dbo->setQuery(
1371 - $dbo->getQuery(true)
1372 - ->delete($dbo->qn('#__vikbooking_orderhistory'))
1373 - ->where($dbo->qn('idorder') . ' = ' . (int) $booking['id'])
1374 - );
1375 - $dbo->execute();
1427 + // push custom extra service
1428 + $current_extras[] = $new_extra;
1376 1429
1377 - // delete the booking record
1378 - $dbo->setQuery(
1379 - $dbo->getQuery(true)
1380 - ->delete($dbo->qn('#__vikbooking_orders'))
1381 - ->where($dbo->qn('id') . ' = ' . (int) $booking['id'])
1382 - );
1383 - $dbo->execute();
1430 + // push the custom extra service in the new list
1431 + $new_extras[] = $new_extra;
1432 + }
1384 1433
1385 - // in case of split stay booking, remove the transient
1386 - if ($booking['split_stay']) {
1387 - VBOFactory::getConfig()->remove('split_stay_' . $booking['id']);
1388 - }
1389 - }
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();
1390 1442
1391 - if ($notify_otas) {
1392 - $vcm_autosync = VikBooking::vcmAutoUpdate();
1393 - if ($vcm_autosync > 0) {
1394 - $vcm_obj = VikBooking::getVcmInvoker();
1395 - $vcm_obj->setOids([$booking['id']])->setSyncType('cancel');
1396 - $sync_result = $vcm_obj->doSync();
1397 - if ($sync_result === false) {
1398 - // set error message
1399 - $vcm_err = $vcm_obj->getError();
1400 - $this->setError(JText::translate('VBCHANNELMANAGERRESULTKO') . (!empty($vcm_err) ? ' - ' . $vcm_err : ''));
1401 - }
1402 - }
1403 - }
1443 + // register history description row
1444 + $history_descr_rows[] = sprintf(
1445 + 'New extras: %s.',
1446 + implode(', ', array_column($new_extras, 'name'))
1447 + );
1404 1448
1405 - return true;
1406 - }
1449 + // check if we need to increase the booking total amount
1450 + $new_extras_cost = array_sum(array_column($new_extras, 'cost'));
1407 1451
1408 - /**
1409 - * Sets a booking ID to confirmed according to the provided options.
1410 - *
1411 - * @param array $options List of details to perform the update.
1412 - *
1413 - * @return bool
1414 - *
1415 - * @since 1.17.3 (J) - 1.7.3 (WP)
1416 - */
1417 - public function setConfirmed(array $options)
1418 - {
1419 - $dbo = JFactory::getDbo();
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 + }
1420 1458
1421 - // access the current booking details, if any
1422 - $booking = $this->getBooking();
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']));
1423 1463
1424 - // access the current rooms booked, if any
1425 - $roomBooking = $this->getRoomBooking();
1464 + // register history description row
1465 + $history_descr_rows[] = sprintf(
1466 + 'Booking total cost difference calculated: %d.',
1467 + (float) $options['cost_difference']
1468 + );
1426 1469
1427 - // gather modification options
1428 - $booking_id = $options['booking_id'] ?? $booking['id'] ?? 0;
1470 + // calculate the cost difference just for the rooms
1471 + $rooms_cost_difference = (float) $options['cost_difference'] - $new_extras_cost;
1429 1472
1430 - if (!$booking_id) {
1431 - $this->setError('Missing booking ID.');
1432 - return false;
1433 - }
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 + }
1434 1484
1435 - if (!$booking) {
1436 - // load current booking record if not injected
1437 - $booking = VikBooking::getBookingInfoFromID($booking_id);
1438 - if (!$booking) {
1439 - $this->setError('Booking not found.');
1440 - return false;
1441 - }
1442 - }
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 + }
1443 1497
1444 - if (!$roomBooking) {
1445 - // load current rooms booked
1446 - $roomBooking = VikBooking::loadOrdersRoomsData($booking_id);
1447 - }
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 + }
1448 1502
1449 - // make sure the booking status is not already confirmed
1450 - if (!strcasecmp($booking['status'], 'confirmed')) {
1451 - $this->setError('Booking is already confirmed.');
1452 - return false;
1453 - }
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 + }
1454 1511
1455 - // memorize the original booking status for VCM in case of OTA booking
1456 - $original_book_status = null;
1457 - if (!empty($booking['idorderota']) && !empty($booking['channel'])) {
1458 - $original_book_status = $booking['status'];
1459 - }
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 + }
1460 1524
1461 - // availability helper
1462 - $av_helper = VikBooking::getAvailabilityInstance(true);
1525 + // update booking history
1526 + $history_obj = VikBooking::getBookingHistoryInstance($booking_id);
1463 1527
1464 - // room stay dates in case of split stay
1465 - $room_stay_dates = [];
1466 - if ($booking['split_stay']) {
1467 - $room_stay_dates = VBOFactory::getConfig()->getArray('split_stay_' . $booking['id'], []);
1468 - }
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 + }
1469 1536
1470 - // make sure all rooms are available for confirmation
1471 - $turnover_secs = VikBooking::getHoursRoomAvail() * 3600;
1472 - $realback = $turnover_secs + $booking['checkout'];
1473 - $allbook = true;
1474 - $notavail = [];
1537 + // update Booking History
1538 + $history_obj->store('MB', $caller_id . ($history_descr_rows ? "\n" . implode("\n", $history_descr_rows) : ''));
1475 1539
1476 - /**
1477 - * We need to calculate a minus operator for each room that was booked more than once.
1478 - * In case we are confirming a booking for more than one unit of the same room, we need to
1479 - * make sure the calculation is made properly, as only one unit of that room could be free.
1480 - */
1481 - $units_minus_oper = [];
1482 - foreach ($roomBooking as $ind => $or) {
1483 - if (!isset($units_minus_oper[$or['idroom']])) {
1484 - $units_minus_oper[$or['idroom']] = -1;
1485 - }
1486 - // increase counter
1487 - $units_minus_oper[$or['idroom']]++;
1488 - if (!empty($room_stay_dates)) {
1489 - // split stay rooms never have the same stay dates, but they should also be different rooms
1490 - $units_minus_oper[$or['idroom']] = 0;
1491 - }
1492 - }
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 + }
1493 1547
1494 - // check availability for each room involved
1495 - foreach ($roomBooking as $ind => $or) {
1496 - // determine proper values for this room
1497 - $room_stay_checkin = $booking['checkin'];
1498 - $room_stay_checkout = $booking['checkout'];
1499 - $room_stay_nights = $booking['days'];
1500 - if ($booking['split_stay'] && $room_stay_dates && isset($room_stay_dates[$ind]) && $room_stay_dates[$ind]['idroom'] == $or['idroom']) {
1501 - $room_stay_checkin = $room_stay_dates[$ind]['checkin_ts'] ?: $room_stay_dates[$ind]['checkin'];
1502 - $room_stay_checkout = $room_stay_dates[$ind]['checkout_ts'] ?: $room_stay_dates[$ind]['checkout'];
1503 - $room_stay_nights = $av_helper->countNightsOfStay($room_stay_checkin, $room_stay_checkout);
1504 - // inject nights calculated for this room
1505 - $room_stay_dates[$ind]['nights'] = $room_stay_nights;
1506 - }
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 + }
1507 1575
1508 - // get room record
1509 - $room_record = VikBooking::getRoomInfo($or['idroom']);
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 + }
1510 1594
1511 - // check if the room is available
1512 - if (!VikBooking::roomBookable($or['idroom'], (($room_record['units'] ?? 0) - $units_minus_oper[$or['idroom']]), $room_stay_checkin, $room_stay_checkout)) {
1513 - $allbook = false;
1514 - $notavail[] = $room_record['name'] ?? '?';
1515 - }
1516 - }
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 + }
1517 1604
1518 - // ensure all rooms involved were available or forced to be
1519 - if (!$allbook && !($options['force_availability'] ?? false)) {
1520 - $this->setError(sprintf('Some rooms are no longer available: %s', implode(', ', $notavail)));
1521 - return false;
1522 - }
1605 + return true;
1606 + }
1523 1607
1524 - // occupy the involved rooms on the db
1525 - foreach ($roomBooking as $ind => $or) {
1526 - // determine proper values for this room
1527 - $room_stay_checkin = $booking['checkin'];
1528 - $room_stay_checkout = $booking['checkout'];
1529 - $room_stay_realback = $realback;
1530 - if ($booking['split_stay'] && $room_stay_dates && isset($room_stay_dates[$ind]) && $room_stay_dates[$ind]['idroom'] == $or['idroom']) {
1531 - $room_stay_checkin = $room_stay_dates[$ind]['checkin_ts'] ?: $room_stay_dates[$ind]['checkin'];
1532 - $room_stay_checkout = $room_stay_dates[$ind]['checkout_ts'] ?: $room_stay_dates[$ind]['checkout'];
1533 - $room_stay_realback = $turnover_secs + $room_stay_checkout;
1534 - }
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();
1535 1622
1536 - // build busy record
1537 - $busy_record = new stdClass;
1538 - $busy_record->idroom = (int) $or['idroom'];
1539 - $busy_record->checkin = (int) $room_stay_checkin;
1540 - $busy_record->checkout = (int) $room_stay_checkout;
1541 - $busy_record->realback = (int) $room_stay_realback;
1623 + $booking_id = $options['booking_id'] ?? 0;
1624 + $canc_reason = $options['cancellation_reason'] ?? '';
1625 + $purge_remove = $options['purge_remove'] ?? false;
1542 1626
1543 - // store busy record and obtain the newly created ID
1544 - $dbo->insertObject('#__vikbooking_busy', $busy_record, 'id');
1545 - $lid = $busy_record->id ?? 0;
1627 + if ($reCreating && $this->prevBookingRegistry && $this->prevBookingRegistry->getID() == $booking_id) {
1628 + $booking = $this->prevBookingRegistry->getData();
1629 + } else {
1630 + $booking = VikBooking::getBookingInfoFromID($booking_id);
1631 + }
1546 1632
1547 - // build busy relation record
1548 - $obusy_record = new stdClass;
1549 - $obusy_record->idorder = (int) $booking['id'];
1550 - $obusy_record->idbusy = (int) $lid;
1633 + if (!$booking) {
1634 + $this->setError('Booking not found.');
1635 + return false;
1636 + }
1551 1637
1552 - // store busy relation record
1553 - $dbo->insertObject('#__vikbooking_ordersbusy', $obusy_record, 'id');
1554 - }
1638 + if (!$reCreating && $booking['status'] === 'cancelled' && !$purge_remove) {
1639 + $this->setError(sprintf('Booking ID %d is already cancelled.', $booking['id']));
1640 + return false;
1641 + }
1555 1642
1556 - // delete temporarily locked records, if any
1557 - $dbo->setQuery(
1558 - $dbo->getQuery(true)
1559 - ->delete($dbo->qn('#__vikbooking_tmplock'))
1560 - ->where($dbo->qn('idorder') . ' = ' . (int) $booking['id'])
1561 - );
1562 - $dbo->execute();
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 + }
1563 1653
1564 - // update booking status (and notes, if any)
1565 - $q = $dbo->getQuery(true)
1566 - ->update($dbo->qn('#__vikbooking_orders'))
1567 - ->set($dbo->qn('status') . ' = ' . $dbo->q('confirmed'))
1568 - ->where($dbo->qn('id') . ' = ' . (int) $booking['id']);
1569 - if ($options['extra_notes'] ?? '') {
1570 - // update administrator notes
1571 - $q->set($dbo->qn('adminnotes') . ' = ' . $dbo->q(trim($booking['adminnotes'] . "\n" . $options['extra_notes'])));
1572 - }
1573 - $dbo->setQuery($q);
1574 - $dbo->execute();
1654 + // access the current user
1655 + $now_user = JFactory::getUser();
1575 1656
1576 - // set booking confirmation number
1577 - $confirmnumber = VikBooking::generateConfirmNumber($booking['id'], true);
1657 + // whether OTAs should be notified
1658 + $notify_otas = false;
1578 1659
1579 - // update booking history
1580 - $history_obj = VikBooking::getBookingHistoryInstance($booking['id']);
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 + }
1581 1674
1582 - $now_user = JFactory::getUser();
1583 - $caller_id = $now_user->name ? "({$now_user->name})" : '';
1584 - if ($this->getCaller()) {
1585 - $caller_id = '(' . $this->getCaller() . ')';
1586 - if ($this->getHistoryData()) {
1587 - $history_obj->setExtraData($this->getHistoryData());
1588 - }
1589 - }
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();
1590 1682
1591 - // update Booking History
1592 - $history_obj->store('TC', $caller_id);
1683 + if (!$reCreating && $booking['status'] == 'confirmed') {
1684 + // turn flag on
1685 + $notify_otas = true;
1686 + }
1593 1687
1594 - // check if some of the rooms booked have shared calendars
1595 - VikBooking::updateSharedCalendars($booking['id'], array_column($roomBooking, 'idroom'), $booking['checkin'], $booking['checkout']);
1688 + if (!$reCreating) {
1689 + // access history object
1690 + $history_obj = VikBooking::getBookingHistoryInstance($booking['id']);
1596 1691
1597 - // assign room specific unit(s)
1598 - $set_room_indexes = VikBooking::autoRoomUnit();
1599 - $room_indexes_usemap = [];
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 + }
1600 1699
1601 - foreach ($roomBooking as $kor => $or) {
1602 - // determine proper values for this room
1603 - $room_stay_checkin = $booking['checkin'];
1604 - $room_stay_checkout = $booking['checkout'];
1605 - $room_stay_nights = $booking['days'];
1606 - if ($booking['split_stay'] && $room_stay_dates && isset($room_stay_dates[$kor]) && $room_stay_dates[$kor]['idroom'] == $or['idroom']) {
1607 - $room_stay_checkin = $room_stay_dates[$kor]['checkin_ts'] ?: $room_stay_dates[$kor]['checkin'];
1608 - $room_stay_checkout = $room_stay_dates[$kor]['checkout_ts'] ?: $room_stay_dates[$kor]['checkout'];
1609 - $room_stay_nights = $room_stay_dates[$kor]['nights'];
1610 - }
1700 + // update Booking History
1701 + $history_obj->store('CB', $caller_id);
1702 + }
1703 + }
1611 1704
1612 - // assign room specific unit
1613 - if ($set_room_indexes === true) {
1614 - $room_indexes = VikBooking::getRoomUnitNumsAvailable($booking, $or['idroom']);
1615 - $use_ind_key = 0;
1616 - if ($room_indexes) {
1617 - if (!isset($room_indexes_usemap[$or['idroom']])) {
1618 - $room_indexes_usemap[$or['idroom']] = $use_ind_key;
1619 - } else {
1620 - $use_ind_key = $room_indexes_usemap[$or['idroom']];
1621 - }
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 + }
1622 1714
1623 - // update room-reservation record by assigning the room index (unit)
1624 - $dbo->setQuery(
1625 - $dbo->getQuery(true)
1626 - ->update($dbo->qn('#__vikbooking_ordersrooms'))
1627 - ->set($dbo->qn('roomindex') . ' = ' . (int) $room_indexes[$use_ind_key])
1628 - ->where($dbo->qn('id') . ' = ' . (int) $or['id'])
1629 - );
1630 - $dbo->execute();
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 + }
1631 1731
1632 - // increase index counter
1633 - $room_indexes_usemap[$or['idroom']]++;
1634 - }
1635 - }
1636 - }
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();
1637 1739
1638 - // Invoke Channel Manager
1639 - $vcm_autosync = VikBooking::vcmAutoUpdate();
1640 - if ($vcm_autosync > 0) {
1641 - $vcm_obj = VikBooking::getVcmInvoker();
1642 - $vcm_obj->setOids([$booking['id']])->setSyncType('new')->setOriginalStatuses([$original_book_status]);
1643 - $sync_result = $vcm_obj->doSync();
1644 - if ($sync_result === false) {
1645 - // set error message
1646 - $vcm_err = $vcm_obj->getError();
1647 - $this->setError(JText::translate('VBCHANNELMANAGERRESULTKO') . (!empty($vcm_err) ? ' - ' . $vcm_err : ''));
1648 - }
1649 - } elseif (is_file(VCM_SITE_PATH . DIRECTORY_SEPARATOR . 'helpers' . DIRECTORY_SEPARATOR . 'synch.vikbooking.php')) {
1650 - // set the necessary action to invoke VCM
1651 - $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']);
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();
1652 1749
1653 - $this->setChannelManagerAction(JText::translate('VBCHANNELMANAGERINVOKEASK') . ' <button type="button" class="btn btn-primary" onclick="document.location.href=\'' . $vcm_sync_url . '\';">' . JText::translate('VBCHANNELMANAGERSENDRQ') . '</button>');
1654 - }
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();
1655 1757
1656 - // check if the guest should be notified via email
1657 - if ($options['notify'] ?? null) {
1658 - // send email notification to guest
1659 - VikBooking::sendBookingEmail($booking['id'], ['guest']);
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();
1660 1765
1661 - // SMS skipping the administrator
1662 - VikBooking::sendBookingSMS($booking['id'], ['admin']);
1663 - }
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();
1664 1773
1665 - return true;
1666 - }
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 + }
1667 1779
1668 - /**
1669 - * Tells if the booking record set can be modified and/or cancelled.
1670 - *
1671 - * @return array
1672 - *
1673 - * @since 1.16.10 (J) - 1.6.10 (WP)
1674 - */
1675 - public function getAlterationDetails()
1676 - {
1677 - $dbo = JFactory::getDbo();
1780 + if ($reCreating === true) {
1781 + // when updating a booking record, get rid of all rooms previously assigned
1678 1782
1679 - $booking = $this->getBooking();
1680 - $roomBooking = $this->getRoomBooking();
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();
1681 1790
1682 - if (!$booking || !$roomBooking) {
1683 - $this->setError('Missing booking or room booking record details.');
1684 - return [];
1685 - }
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 + }
1686 1796
1687 - // gather room booking tariffs
1688 - $tars = [];
1689 - foreach ($roomBooking as $kor => $or) {
1690 - $num = $kor + 1;
1691 - if (!empty($order['pkg']) || (!empty($or['cust_cost']) && $or['cust_cost'] > 0.00)) {
1692 - // package or custom cost set from the back-end
1693 - continue;
1694 - }
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 + }
1695 1810
1696 - // get room tariff details
1697 - $dbo->setQuery(
1698 - $dbo->getQuery(true)
1699 - ->select($dbo->qn('t') . '.*')
1700 - ->select([
1701 - $dbo->qn('p.name'),
1702 - $dbo->qn('p.free_cancellation'),
1703 - $dbo->qn('p.canc_deadline'),
1704 - $dbo->qn('p.canc_policy'),
1705 - ])
1706 - ->from($dbo->qn('#__vikbooking_dispcost', 't'))
1707 - ->leftJoin($dbo->qn('#__vikbooking_prices', 'p') . ' ON ' . $dbo->qn('t.idprice') . ' = ' . $dbo->qn('p.id'))
1708 - ->where($dbo->qn('t.id') . ' = ' . (int) ($or['idtar'] ?? 0))
1709 - );
1710 - $tar = $dbo->loadAssoc();
1811 + return true;
1812 + }
1711 1813
1712 - if ($tar) {
1713 - // push room booking tariff
1714 - $tars[$num] = $tar;
1715 - }
1716 - }
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();
1717 1826
1718 - // count days to arrival
1719 - $days_to_arrival = 0;
1720 - $now_info = getdate();
1721 - $checkin_info = getdate($booking['checkin'] ?? 0);
1722 - if ($now_info[0] < $checkin_info[0]) {
1723 - while ($now_info[0] < $checkin_info[0]) {
1724 - if (!($now_info['mday'] != $checkin_info['mday'] || $now_info['mon'] != $checkin_info['mon'] || $now_info['year'] != $checkin_info['year'])) {
1725 - break;
1726 - }
1727 - $days_to_arrival++;
1728 - $now_info = getdate(mktime(0, 0, 0, $now_info['mon'], ($now_info['mday'] + 1), $now_info['year']));
1729 - }
1730 - }
1827 + // access the current booking details, if any
1828 + $booking = $this->getBooking();
1731 1829
1732 - // check if the rate plan(s) are refundable
1733 - $is_refundable = 0;
1734 - $daysadv_refund_arr = [];
1735 - $daysadv_refund = 0;
1736 - $canc_policy = '';
1737 - foreach ($tars as $num => $tar) {
1738 - if (!$tar['free_cancellation']) {
1739 - // if at least one rate plan is non-refundable, the whole reservation cannot be cancelled
1740 - $is_refundable = 0;
1741 - $daysadv_refund_arr = [];
1742 - break;
1743 - }
1744 - $is_refundable = 1;
1745 - $daysadv_refund_arr[] = $tar['canc_deadline'];
1746 - }
1830 + // access the current rooms booked, if any
1831 + $roomBooking = $this->getRoomBooking();
1747 1832
1748 - // get the rate plan with the lowest cancellation deadline
1749 - $daysadv_refund = $daysadv_refund_arr ? min($daysadv_refund_arr) : $daysadv_refund;
1750 - if ($daysadv_refund > 0) {
1751 - foreach ($tars as $num => $tar) {
1752 - if ($tar['free_cancellation'] && $tar['canc_deadline'] == $daysadv_refund) {
1753 - // get the cancellation policy from the first rate plan with free cancellation and same cancellation deadline
1754 - $canc_policy = $tar['canc_policy'];
1755 - break;
1756 - }
1757 - }
1758 - }
1833 + // gather modification options
1834 + $booking_id = $options['booking_id'] ?? $booking['id'] ?? 0;
1759 1835
1760 - // access global settings to determine the alterations available
1761 - $resmodcanc = VikBooking::getReservationModCanc();
1762 - $resmodcanc = !$days_to_arrival ? 0 : $resmodcanc;
1763 - $resmodcancmin = VikBooking::getReservationModCancMin();
1836 + if (!$booking_id) {
1837 + $this->setError('Missing booking ID.');
1838 + return false;
1839 + }
1764 1840
1765 - // build alteration deadline date
1766 - $checkin_dt = JFactory::getDate(date('Y-m-d', ($booking['checkin'] ?? 0)));
1767 - $checkin_dt->modify("-{$resmodcancmin} days");
1768 - $alteration_deadline = $checkin_dt->format('Y-m-d');
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 + }
1769 1849
1770 - return [
1771 - 'refundable' => ($resmodcanc > 1 && $resmodcanc != 2 && $is_refundable > 0 && $daysadv_refund <= $days_to_arrival && $days_to_arrival >= $resmodcancmin),
1772 - 'modifiable' => ($resmodcanc > 1 && $resmodcanc != 3 && $days_to_arrival >= $resmodcancmin),
1773 - 'alteration_disabled' => $resmodcanc === 0,
1774 - 'request_alteration' => $resmodcanc === 1,
1775 - 'cancellation_policy' => $canc_policy ?: null,
1776 - 'alteration_deadline' => $alteration_deadline,
1777 - ];
1778 - }
1850 + if (!$roomBooking) {
1851 + // load current rooms booked
1852 + $roomBooking = VikBooking::loadOrdersRoomsData($booking_id);
1853 + }
1779 1854
1780 - /**
1781 - * Tells whether a given booking ID can be moved to new stay dates. Ignores rooms individual stay
1782 - * dates or split stay reservations. Should be called when changing the dates for a whole booking.
1783 - *
1784 - * @param int $booking_id The booking ID to modify.
1785 - * @param int|string $new_checkin The new check-in date (timestamp or date string with no time).
1786 - * @param int|string $new_checkout The new check-out date (timestamp or date string with no time).
1787 - *
1788 - * @return bool True if all booking rooms are available on the new dates.
1789 - *
1790 - * @throws Exception
1791 - *
1792 - * @since 1.18.2 (J) - 1.8.2 (WP)
1793 - */
1794 - public function bookingModifiable(int $booking_id, $new_checkin, $new_checkout)
1795 - {
1796 - // load all booking rooms
1797 - $booking_rooms = VikBooking::loadOrdersRoomsData($booking_id);
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 + }
1798 1860
1799 - if (!$booking_rooms) {
1800 - throw new Exception('Could not find any rooms booked within the reservation.', 500);
1801 - }
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 + }
1802 1866
1803 - if (empty($new_checkin) || empty($new_checkout)) {
1804 - throw new Exception('Missing stay dates.', 500);
1805 - }
1867 + // availability helper
1868 + $av_helper = VikBooking::getAvailabilityInstance(true);
1806 1869
1807 - // load the booking occupied record IDs, if any
1808 - $busy_ids = VikBooking::loadBookingBusyIds($booking_id);
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 + }
1809 1875
1810 - // load check-in and check-out times
1811 - list($checkin_h, $checkin_m, $checkout_h, $checkout_m) = $this->loadCheckinOutTimes();
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 = [];
1812 1881
1813 - // get the new stay timestamps
1814 - $new_checkin = is_numeric($new_checkin) ? date('Y-m-d', $new_checkin) : $new_checkin;
1815 - $new_checkout = is_numeric($new_checkout) ? date('Y-m-d', $new_checkout) : $new_checkout;
1816 - $from_ts = VikBooking::getDateTimestamp($new_checkin, $checkin_h, $checkin_m);
1817 - $to_ts = VikBooking::getDateTimestamp($new_checkout, $checkout_h, $checkout_m);
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 + }
1818 1899
1819 - if ($to_ts <= $from_ts) {
1820 - throw new Exception('Invalid stay dates provided.', 500);
1821 - }
1900 + // list of room-units pool
1901 + $roomUnits = [];
1822 1902
1823 - // count room equal units
1824 - $rooms_units_counter = [];
1825 - foreach ($booking_rooms as $booking_room) {
1826 - $rooms_units_counter[$booking_room['idroom']] = ($rooms_counter[$booking_room['idroom']] ?? -1) + 1;
1827 - }
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 + }
1828 1916
1829 - // check the availability for each room booked
1830 - $rooms_parsed = [];
1831 - foreach ($booking_rooms as $booking_room) {
1832 - if (in_array($booking_room['idroom'], $rooms_parsed)) {
1833 - continue;
1834 - }
1917 + // get room record
1918 + $room_record = VikBooking::getRoomInfo($or['idroom']);
1835 1919
1836 - // count effective units to check
1837 - $effective_units = $booking_room['tot_units'] - $rooms_units_counter[$booking_room['idroom']];
1920 + // set room (total) units
1921 + $roomUnits[$or['idroom']] = (int) ($room_record['units'] ?? 1);
1838 1922
1839 - // check if the room is available on the new dates
1840 - if (!VikBooking::roomBookable($booking_room['idroom'], $effective_units, $from_ts, $to_ts, $busy_ids)) {
1841 - // the room is occupied
1842 - return false;
1843 - }
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 + }
1844 1929
1845 - // push room just checked
1846 - $rooms_parsed[] = $booking_room['idroom'];
1847 - }
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 + }
1848 1935
1849 - return true;
1850 - }
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 + }
1851 1947
1852 - /**
1853 - * Attempts to invoke the payment processor assigned to the current booking.
1854 - *
1855 - * @param array $card Optional credit card details to bind.
1856 - *
1857 - * @return object The payment processor dispatcher instance.
1858 - *
1859 - * @throws Exception
1860 - *
1861 - * @since 1.16.10 (J) - 1.6.10 (WP)
1862 - * @since 1.17.6 (J) - 1.7.6 (WP) added "tn_metadata" details.
1863 - */
1864 - public function getPaymentProcessor(array $card = [])
1865 - {
1866 - $booking = $this->getProperties();
1867 - $processor = null;
1868 - $payment = [];
1948 + // determine the room units to occupy (to support closure re-confirmations)
1949 + $bookUnitsCount = (int) (intval($booking['closure'] ?? 0) ? ($roomUnits[$or['idroom']] ?? 1) : 1);
1869 1950
1870 - if (!$booking) {
1871 - throw new Exception('Missing booking details', 500);
1872 - }
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;
1873 1958
1874 - if (!empty($booking['idpayment'])) {
1875 - $payment = VikBooking::getPayment($booking['idpayment']);
1876 - }
1959 + // store busy record and obtain the newly created ID
1960 + $dbo->insertObject('#__vikbooking_busy', $busy_record, 'id');
1961 + $lid = $busy_record->id ?? 0;
1877 1962
1878 - if (!$payment) {
1879 - throw new Exception('Missing payment method details', 500);
1880 - }
1963 + // build busy relation record
1964 + $obusy_record = new stdClass;
1965 + $obusy_record->idorder = (int) $booking['id'];
1966 + $obusy_record->idbusy = (int) $lid;
1881 1967
1882 - // set payment details internally
1883 - $this->set('_payment_info', $payment);
1968 + // store busy relation record
1969 + $dbo->insertObject('#__vikbooking_ordersbusy', $obusy_record, 'id');
1970 + }
1971 + }
1884 1972
1885 - if ($card) {
1886 - // inject CC details for the payment processor
1887 - $booking['card'] = $card;
1888 - }
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();
1889 1980
1890 - // get the booking customer record, if any
1891 - $customer = $this->getCustomer();
1892 - if (!$customer) {
1893 - $customer = VikBooking::getCPinInstance()->getCustomerFromBooking($booking['id']);
1894 - }
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();
1895 1992
1896 - // build and inject transaction metadata
1897 - $booking['tn_metadata'] = [
1898 - 'booking_id' => $booking['id'],
1899 - 'source' => (($booking['channel'] ?? '') ?: 'Website'),
1900 - 'ota_booking_id' => (($booking['idorderota'] ?? '') ?: ''),
1901 - 'guest_name' => implode(' ', array_filter([($customer['first_name'] ?? ''), ($customer['last_name'] ?? '')])),
1902 - 'guest_email' => $customer['email'] ?? null,
1903 - 'guest_phone' => $customer['phone'] ?? null,
1904 - 'guest_country' => $customer['country'] ?? null,
1905 - ];
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 + }
1906 1997
1907 - /**
1998 + // set booking confirmation number
1999 + $confirmnumber = VikBooking::generateConfirmNumber($booking['id'], true);
2000 +
2001 + // assign room specific unit(s)
2002 + $set_room_indexes = VikBooking::autoRoomUnit();
2003 + $room_indexes_usemap = [];
2004 +
2005 + foreach ($roomBooking as $kor => $or) {
2006 + // determine proper values for this room
2007 + $room_stay_checkin = $booking['checkin'];
2008 + $room_stay_checkout = $booking['checkout'];
2009 + $room_stay_nights = $booking['days'];
2010 + if ($booking['split_stay'] && $room_stay_dates && isset($room_stay_dates[$kor]) && $room_stay_dates[$kor]['idroom'] == $or['idroom']) {
2011 + $room_stay_checkin = $room_stay_dates[$kor]['checkin_ts'] ?: $room_stay_dates[$kor]['checkin'];
2012 + $room_stay_checkout = $room_stay_dates[$kor]['checkout_ts'] ?: $room_stay_dates[$kor]['checkout'];
2013 + $room_stay_nights = $room_stay_dates[$kor]['nights'];
2014 + }
2015 +
2016 + // assign room specific unit
2017 + if ($set_room_indexes === true) {
2018 + $room_indexes = VikBooking::getRoomUnitNumsAvailable($booking, $or['idroom']);
2019 + $use_ind_key = 0;
2020 + if ($room_indexes) {
2021 + if (!isset($room_indexes_usemap[$or['idroom']])) {
2022 + $room_indexes_usemap[$or['idroom']] = $use_ind_key;
2023 + } else {
2024 + $use_ind_key = $room_indexes_usemap[$or['idroom']];
2025 + }
2026 +
2027 + // update room-reservation record by assigning the room index (unit)
2028 + $dbo->setQuery(
2029 + $dbo->getQuery(true)
2030 + ->update($dbo->qn('#__vikbooking_ordersrooms'))
2031 + ->set($dbo->qn('roomindex') . ' = ' . (int) $room_indexes[$use_ind_key])
2032 + ->where($dbo->qn('id') . ' = ' . (int) $or['id'])
2033 + );
2034 + $dbo->execute();
2035 +
2036 + // increase index counter
2037 + $room_indexes_usemap[$or['idroom']]++;
2038 + }
2039 + }
2040 + }
2041 +
2042 + // check if some of the rooms booked have shared calendars
2043 + VikBooking::updateSharedCalendars($booking['id'], array_column($roomBooking, 'idroom'), $booking['checkin'], $booking['checkout']);
2044 +
2045 + // update booking history
2046 + $history_obj = VikBooking::getBookingHistoryInstance($booking['id']);
2047 +
2048 + $now_user = JFactory::getUser();
2049 + $caller_id = $now_user->name ? "({$now_user->name})" : '';
2050 + if ($this->getCaller()) {
2051 + $caller_id = '(' . $this->getCaller() . ')';
2052 + if ($this->getHistoryData()) {
2053 + $history_obj->setExtraData($this->getHistoryData());
2054 + }
2055 + }
2056 +
2057 + // update Booking History
2058 + $history_obj->store('TC', $caller_id);
2059 +
2060 + // Invoke Channel Manager
2061 + $vcm_autosync = VikBooking::vcmAutoUpdate();
2062 + if ($vcm_autosync > 0) {
2063 + $vcm_obj = VikBooking::getVcmInvoker();
2064 + $vcm_obj->setOids([$booking['id']])->setSyncType('new')->setOriginalStatuses([$original_book_status]);
2065 + $sync_result = $vcm_obj->doSync();
2066 + if ($sync_result === false) {
2067 + // set error message
2068 + $vcm_err = $vcm_obj->getError();
2069 + $this->setError(JText::translate('VBCHANNELMANAGERRESULTKO') . (!empty($vcm_err) ? ' - ' . $vcm_err : ''));
2070 + }
2071 + } elseif (is_file(VCM_SITE_PATH . DIRECTORY_SEPARATOR . 'helpers' . DIRECTORY_SEPARATOR . 'synch.vikbooking.php')) {
2072 + // set the necessary action to invoke VCM
2073 + $vcm_sync_url = 'index.php?option=com_vikbooking&task=invoke_vcm&stype=new&cid[]=' . $booking['id'] . '&returl=' . urlencode('index.php?option=com_vikbooking&task=editorder&cid[]=' . $booking['id']);
2074 +
2075 + $this->setChannelManagerAction(JText::translate('VBCHANNELMANAGERINVOKEASK') . ' <button type="button" class="btn btn-primary" onclick="document.location.href=\'' . $vcm_sync_url . '\';">' . JText::translate('VBCHANNELMANAGERSENDRQ') . '</button>');
2076 + }
2077 +
2078 + // check if the guest should be notified via email
2079 + if ($options['notify'] ?? null) {
2080 + // send email notification to guest
2081 + VikBooking::sendBookingEmail($booking['id'], ['guest']);
2082 +
2083 + // SMS skipping the administrator
2084 + VikBooking::sendBookingSMS($booking['id'], ['admin']);
2085 + }
2086 +
2087 + return true;
2088 + }
2089 +
2090 + /**
2091 + * Tells if the booking record set can be modified and/or cancelled.
2092 + *
2093 + * @return array
2094 + *
2095 + * @since 1.16.10 (J) - 1.6.10 (WP)
2096 + */
2097 + public function getAlterationDetails()
2098 + {
2099 + $dbo = JFactory::getDbo();
2100 +
2101 + $booking = $this->getBooking();
2102 + $roomBooking = $this->getRoomBooking();
2103 +
2104 + if (!$booking || !$roomBooking) {
2105 + $this->setError('Missing booking or room booking record details.');
2106 + return [];
2107 + }
2108 +
2109 + // gather room booking tariffs
2110 + $tars = [];
2111 + foreach ($roomBooking as $kor => $or) {
2112 + $num = $kor + 1;
2113 + if (!empty($order['pkg']) || (!empty($or['cust_cost']) && $or['cust_cost'] > 0.00)) {
2114 + // package or custom cost set from the back-end
2115 + continue;
2116 + }
2117 +
2118 + // get room tariff details
2119 + $dbo->setQuery(
2120 + $dbo->getQuery(true)
2121 + ->select($dbo->qn('t') . '.*')
2122 + ->select([
2123 + $dbo->qn('p.name'),
2124 + $dbo->qn('p.free_cancellation'),
2125 + $dbo->qn('p.canc_deadline'),
2126 + $dbo->qn('p.canc_policy'),
2127 + ])
2128 + ->from($dbo->qn('#__vikbooking_dispcost', 't'))
2129 + ->leftJoin($dbo->qn('#__vikbooking_prices', 'p') . ' ON ' . $dbo->qn('t.idprice') . ' = ' . $dbo->qn('p.id'))
2130 + ->where($dbo->qn('t.id') . ' = ' . (int) ($or['idtar'] ?? 0))
2131 + );
2132 + $tar = $dbo->loadAssoc();
2133 +
2134 + if ($tar) {
2135 + // push room booking tariff
2136 + $tars[$num] = $tar;
2137 + }
2138 + }
2139 +
2140 + // count days to arrival
2141 + $days_to_arrival = 0;
2142 + $now_info = getdate();
2143 + $checkin_info = getdate($booking['checkin'] ?? 0);
2144 + if ($now_info[0] < $checkin_info[0]) {
2145 + while ($now_info[0] < $checkin_info[0]) {
2146 + if (!($now_info['mday'] != $checkin_info['mday'] || $now_info['mon'] != $checkin_info['mon'] || $now_info['year'] != $checkin_info['year'])) {
2147 + break;
2148 + }
2149 + $days_to_arrival++;
2150 + $now_info = getdate(mktime(0, 0, 0, $now_info['mon'], ($now_info['mday'] + 1), $now_info['year']));
2151 + }
2152 + }
2153 +
2154 + // check if the rate plan(s) are refundable
2155 + $is_refundable = 0;
2156 + $daysadv_refund_arr = [];
2157 + $daysadv_refund = 0;
2158 + $canc_policy = '';
2159 + foreach ($tars as $num => $tar) {
2160 + if (!$tar['free_cancellation']) {
2161 + // if at least one rate plan is non-refundable, the whole reservation cannot be cancelled
2162 + $is_refundable = 0;
2163 + $daysadv_refund_arr = [];
2164 + break;
2165 + }
2166 + $is_refundable = 1;
2167 + $daysadv_refund_arr[] = $tar['canc_deadline'];
2168 + }
2169 +
2170 + // get the rate plan with the lowest cancellation deadline
2171 + $daysadv_refund = $daysadv_refund_arr ? min($daysadv_refund_arr) : $daysadv_refund;
2172 + if ($daysadv_refund > 0) {
2173 + foreach ($tars as $num => $tar) {
2174 + if ($tar['free_cancellation'] && $tar['canc_deadline'] == $daysadv_refund) {
2175 + // get the cancellation policy from the first rate plan with free cancellation and same cancellation deadline
2176 + $canc_policy = $tar['canc_policy'];
2177 + break;
2178 + }
2179 + }
2180 + }
2181 +
2182 + // access global settings to determine the alterations available
2183 + $resmodcanc = VikBooking::getReservationModCanc();
2184 + $resmodcanc = !$days_to_arrival ? 0 : $resmodcanc;
2185 + $resmodcancmin = VikBooking::getReservationModCancMin();
2186 +
2187 + // build alteration deadline date
2188 + $checkin_dt = JFactory::getDate(date('Y-m-d', ($booking['checkin'] ?? 0)));
2189 + $checkin_dt->modify("-{$resmodcancmin} days");
2190 + $alteration_deadline = $checkin_dt->format('Y-m-d');
2191 +
2192 + return [
2193 + 'refundable' => ($resmodcanc > 1 && $resmodcanc != 2 && $is_refundable > 0 && $daysadv_refund <= $days_to_arrival && $days_to_arrival >= $resmodcancmin),
2194 + 'modifiable' => ($resmodcanc > 1 && $resmodcanc != 3 && $days_to_arrival >= $resmodcancmin),
2195 + 'alteration_disabled' => $resmodcanc === 0,
2196 + 'request_alteration' => $resmodcanc === 1,
2197 + 'cancellation_policy' => $canc_policy ?: null,
2198 + 'alteration_deadline' => $alteration_deadline,
2199 + ];
2200 + }
2201 +
2202 + /**
2203 + * Tells whether a given booking ID can be moved to new stay dates. Ignores rooms individual stay
2204 + * dates or split stay reservations. Should be called when changing the dates for a whole booking.
2205 + *
2206 + * @param int $booking_id The booking ID to modify.
2207 + * @param int|string $new_checkin The new check-in date (timestamp or date string with no time).
2208 + * @param int|string $new_checkout The new check-out date (timestamp or date string with no time).
2209 + *
2210 + * @return bool True if all booking rooms are available on the new dates.
2211 + *
2212 + * @throws Exception
2213 + *
2214 + * @since 1.18.2 (J) - 1.8.2 (WP)
2215 + */
2216 + public function bookingModifiable(int $booking_id, $new_checkin, $new_checkout)
2217 + {
2218 + // attempt to access previous booking registry to reduce queries
2219 + if ($this->prevBookingRegistry && $this->prevBookingRegistry->getID() == $booking_id) {
2220 + $booking_rooms = $this->prevBookingRegistry->getRooms();
2221 + } else {
2222 + // load all booking rooms
2223 + $booking_rooms = VikBooking::loadOrdersRoomsData($booking_id);
2224 + }
2225 +
2226 + if (!$booking_rooms) {
2227 + throw new Exception('Could not find any rooms booked within the reservation.', 500);
2228 + }
2229 +
2230 + if (empty($new_checkin) || empty($new_checkout)) {
2231 + throw new Exception('Missing stay dates.', 500);
2232 + }
2233 +
2234 + // load the booking occupied record IDs, if any
2235 + $busy_ids = VikBooking::loadBookingBusyIds($booking_id);
2236 +
2237 + // load check-in and check-out times
2238 + list($checkin_h, $checkin_m, $checkout_h, $checkout_m) = $this->loadCheckinOutTimes();
2239 +
2240 + // get the new stay timestamps
2241 + $new_checkin = is_numeric($new_checkin) ? date('Y-m-d', $new_checkin) : $new_checkin;
2242 + $new_checkout = is_numeric($new_checkout) ? date('Y-m-d', $new_checkout) : $new_checkout;
2243 + $from_ts = VikBooking::getDateTimestamp($new_checkin, $checkin_h, $checkin_m);
2244 + $to_ts = VikBooking::getDateTimestamp($new_checkout, $checkout_h, $checkout_m);
2245 +
2246 + if ($to_ts <= $from_ts) {
2247 + throw new Exception('Invalid stay dates provided.', 500);
2248 + }
2249 +
2250 + // count room equal units
2251 + $rooms_units_counter = [];
2252 + foreach ($booking_rooms as $booking_room) {
2253 + $rooms_units_counter[$booking_room['idroom']] = ($rooms_counter[$booking_room['idroom']] ?? -1) + 1;
2254 + }
2255 +
2256 + // check if the booking modification involves other rooms
2257 + $roomsPool = $this->getRoomsPool();
2258 + $oldRoomIds = array_map('intval', array_column($booking_rooms, 'idroom'));
2259 + $newRoomIds = array_values(array_unique(array_map('intval', array_column($roomsPool, 'id'))));
2260 + $diffIds = array_diff($newRoomIds, $oldRoomIds);
2261 + foreach ($diffIds as $newId) {
2262 + $unitsRequested = 0;
2263 + $totUnits = 0;
2264 + foreach ($roomsPool as $roomData) {
2265 + if ($newId == $roomData['id']) {
2266 + $unitsRequested++;
2267 + $totUnits = (int) ($roomData['units'] ?? 1);
2268 + }
2269 + }
2270 + if (!$unitsRequested) {
2271 + continue;
2272 + }
2273 +
2274 + // update units counter map for multiple units requested
2275 + $rooms_units_counter[$newId] = $unitsRequested - 1;
2276 +
2277 + // push additional room for availability validation
2278 + $booking_rooms[] = [
2279 + 'idroom' => $newId,
2280 + 'tot_units' => $totUnits,
2281 + ];
2282 + }
2283 +
2284 + // check the availability for each room booked
2285 + $rooms_parsed = [];
2286 + foreach ($booking_rooms as $booking_room) {
2287 + if (in_array($booking_room['idroom'], $rooms_parsed)) {
2288 + continue;
2289 + }
2290 +
2291 + // count effective units to check
2292 + $effective_units = $booking_room['tot_units'] - $rooms_units_counter[$booking_room['idroom']];
2293 +
2294 + // check if the room is available on the new dates
2295 + if (!VikBooking::roomBookable($booking_room['idroom'], $effective_units, $from_ts, $to_ts, $busy_ids)) {
2296 + // the room is occupied
2297 + return false;
2298 + }
2299 +
2300 + // push room just checked
2301 + $rooms_parsed[] = $booking_room['idroom'];
2302 + }
2303 +
2304 + return true;
2305 + }
2306 +
2307 + /**
2308 + * Attempts to invoke the payment processor assigned to the current booking.
2309 + *
2310 + * @param array $card Optional credit card details to bind.
2311 + *
2312 + * @return object The payment processor dispatcher instance.
2313 + *
2314 + * @throws Exception
2315 + *
2316 + * @since 1.16.10 (J) - 1.6.10 (WP)
2317 + * @since 1.17.6 (J) - 1.7.6 (WP) added "tn_metadata" details.
2318 + */
2319 + public function getPaymentProcessor(array $card = [])
2320 + {
2321 + $booking = $this->getProperties();
2322 + $processor = null;
2323 + $payment = [];
2324 +
2325 + if (!$booking) {
2326 + throw new Exception('Missing booking details', 500);
2327 + }
2328 +
2329 + if (!empty($booking['idpayment'])) {
2330 + $payment = VikBooking::getPayment($booking['idpayment']);
2331 + }
2332 +
2333 + if (!$payment) {
2334 + throw new Exception('Missing payment method details', 500);
2335 + }
2336 +
2337 + // set payment details internally
2338 + $this->set('_payment_info', $payment);
2339 +
2340 + if ($card) {
2341 + // inject CC details for the payment processor
2342 + $booking['card'] = $card;
2343 + }
2344 +
2345 + // get the booking customer record, if any
2346 + $customer = $this->getCustomer();
2347 + if (!$customer) {
2348 + $customer = VikBooking::getCPinInstance()->getCustomerFromBooking($booking['id']);
2349 + }
2350 +
2351 + // build and inject transaction metadata
2352 + $booking['tn_metadata'] = [
2353 + 'booking_id' => $booking['id'],
2354 + 'source' => (($booking['channel'] ?? '') ?: 'Website'),
2355 + 'ota_booking_id' => (($booking['idorderota'] ?? '') ?: ''),
2356 + 'guest_name' => implode(' ', array_filter([($customer['first_name'] ?? ''), ($customer['last_name'] ?? '')])),
2357 + 'guest_email' => $customer['email'] ?? null,
2358 + 'guest_phone' => $customer['phone'] ?? null,
2359 + 'guest_country' => $customer['country'] ?? null,
2360 + ];
2361 +
2362 + /**
1908 2363 * Trigger event to allow third-party plugins to manipulate the transaction data.
1909 2364 *
1910 - * @since 1.18.5 (J) - 1.8.5 (WP)
2365 + * @since 1.18.5 (J) - 1.8.5 (WP)
1911 2366 */
1912 2367 VBOFactory::getPlatform()->getDispatcher()->trigger('onInitPaymentTransaction', [&$booking, &$payment['params'], $customer]);
1913 2368
1914 - if (VBOPlatformDetection::isWordPress()) {
1915 - /**
1916 - * @wponly The payment gateway is loaded
1917 - * through the apposite dispatcher.
1918 - */
1919 - JLoader::import('adapter.payment.dispatcher');
1920 - $processor = JPaymentDispatcher::getInstance('vikbooking', $payment['file'], $booking, $payment['params']);
1921 - } elseif (VBOPlatformDetection::isJoomla()) {
1922 - /**
1923 - * @joomlaonly The Payment Factory library will invoke the gateway.
1924 - */
1925 - require_once VBO_ADMIN_PATH . DIRECTORY_SEPARATOR . 'payments' . DIRECTORY_SEPARATOR . 'libraries' . DIRECTORY_SEPARATOR . 'factory.php';
1926 - $processor = VBOPaymentFactory::getPaymentInstance($payment['file'], $booking, $payment['params']);
1927 - }
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 + }
1928 2383
1929 - if (!$processor) {
1930 - throw new Exception('Could not invoke the payment processor', 500);
1931 - }
2384 + if (!$processor) {
2385 + throw new Exception('Could not invoke the payment processor', 500);
2386 + }
1932 2387
1933 - // return the valid payment processor instance
1934 - return $processor;
1935 - }
2388 + // return the valid payment processor instance
2389 + return $processor;
2390 + }
1936 2391
1937 - /**
1938 - * Gets the reservation's payment method name.
1939 - *
1940 - * @return string
1941 - *
1942 - * @since 1.16.10 (J) - 1.6.10 (WP)
1943 - */
1944 - public function getPaymentName()
1945 - {
1946 - // access the reserved property
1947 - $payment = (array) $this->get('_payment_info', []);
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', []);
1948 2403
1949 - if (!$payment) {
1950 - return '';
1951 - }
2404 + if (!$payment) {
2405 + return '';
2406 + }
1952 2407
1953 - return $payment['name'] ?? '';
1954 - }
2408 + return $payment['name'] ?? '';
2409 + }
1955 2410
1956 - /**
1957 - * Attempts to get the most recent transaction data for an off-session capturing.
1958 - *
1959 - * @param string $tn_driver Optional payment processor driver name.
1960 - *
1961 - * @return object[] Eligible transaction data list or empty array.
1962 - *
1963 - * @since 1.18.0 (J) - 1.8.0 (WP)
1964 - */
1965 - public function getOffSessionTransactionData(string $tn_driver = '')
1966 - {
1967 - // transaction data validation callback
1968 - $tn_data_callback = function($data) use ($tn_driver) {
1969 - return (is_object($data) && isset($data->driver) && (!$tn_driver || basename($data->driver, '.php') == basename($tn_driver, '.php')) && ($data->future_usage ?? null));
1970 - };
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 + };
1971 2426
1972 - // get previous transactions (in date ascending order)
1973 - $prev_tn_data = (array) VikBooking::getBookingHistoryInstance($this->get('id', 0))->getEventsWithData(['P0', 'PN'], $tn_data_callback);
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);
1974 2429
1975 - if (!$prev_tn_data) {
1976 - return [];
1977 - }
2430 + if (!$prev_tn_data) {
2431 + return [];
2432 + }
1978 2433
1979 - // return the eligible transaction data list in reverse order
1980 - return array_reverse(array_values(array_filter(array_map(function($data) {
1981 - if (is_array($data)) {
1982 - // cast to object
1983 - $data = (object) $data;
1984 - }
1985 - return is_object($data) ? $data : null;
1986 - }, $prev_tn_data))));
1987 - }
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 + }
1988 2443
1989 - /**
1990 - * Attempts to get the credit card value pairs from the current booking.
1991 - *
1992 - * @return array Associative list of CC value-pairs, if any.
1993 - *
1994 - * @since 1.16.10 (J) - 1.6.10 (WP)
1995 - */
1996 - public function getCardValuePairs()
1997 - {
1998 - $booking_info = $this->getProperties();
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();
1999 2454
2000 - if (empty($booking_info['paymentlog']) && empty($booking_info['idorderota'])) {
2001 - // do not proceed when no CC data is available, or cannot be obtained via API
2002 - return [];
2003 - }
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 + }
2004 2459
2005 - // build complete credit card payload, if available
2006 - $cc_payload_str = '';
2460 + // build complete credit card payload, if available
2461 + $cc_payload_str = '';
2007 2462
2008 - // extract CC data from payment logs by ensuring they're not null
2009 - $booking_info['paymentlog'] = (string) $booking_info['paymentlog'];
2010 - if (stripos($booking_info['paymentlog'], 'card number') !== false && strpos($booking_info['paymentlog'], '*') !== false) {
2011 - // matched a log for an OTA CC
2012 - $cc_payload_str = $booking_info['paymentlog'];
2013 - } elseif (preg_match("/(([\d\*]{4,4}\s*){4,4})|(([\d\*]{4,6}\s*){3,3})/", $booking_info['paymentlog'])) {
2014 - // matched a credit card
2015 - $cc_payload_str = $booking_info['paymentlog'];
2016 - }
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 + }
2017 2472
2018 - // check if this is an OTA reservation with remotely decoded CC details required
2019 - $remote_cc_data = [];
2020 - if (!empty($booking_info['idorderota']) && !empty($booking_info['channel'])) {
2021 - // channel source
2022 - $channel_source = (string)$booking_info['channel'];
2023 - if (strpos($booking_info['channel'], '_') !== false) {
2024 - $channelparts = explode('_', $booking_info['channel']);
2025 - $channel_source = $channelparts[0];
2026 - }
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 + }
2027 2482
2028 - // only updated versions of VCM will support remote CC decoding for OTA reservations
2029 - if (class_exists('VCMOtaBooking')) {
2030 - // invoke the OTA Booking helper class from VCM
2031 - $cc_helper = VCMOtaBooking::getInstance([
2032 - 'channel_source' => $channel_source,
2033 - 'ota_id' => $booking_info['idorderota'],
2034 - 'booking' => $booking_info,
2035 - ], $anew = true);
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);
2036 2491
2037 - if (method_exists($cc_helper, 'decodeCreditCardDetails')) {
2038 - $remote_cc_data = $cc_helper->decodeCreditCardDetails();
2039 - // make sure the response was valid
2040 - if (!$remote_cc_data || !empty($remote_cc_data['error'])) {
2041 - // we ignore the error by simply resetting the array
2042 - $remote_cc_data = [];
2043 - }
2044 - }
2045 - }
2046 - }
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 + }
2047 2502
2048 - // check if we have already a full VCC
2049 - if (($remote_cc_data['card_number'] ?? '') && strlen(preg_replace('/[^0-9]/', '', (string) $remote_cc_data['card_number'])) >= 15) {
2050 - // do not merge any local data and return the full VCC details
2051 - return $remote_cc_data;
2052 - }
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 + }
2053 2508
2054 - // merge remotely decoded CC details with parsed payment log (if any)
2055 - return array_merge($remote_cc_data, $this->parseCreditCardValuePairs($cc_payload_str, $remote_cc_data));
2056 - }
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 + }
2057 2512
2058 - /**
2059 - * Given a raw string of credit card key-value pairs from payments log,
2060 - * parse the corresponding keys and values into an associative array.
2061 - * In case of conflicting keys with the remotely decoded CC details,
2062 - * attempts to replace the masked numbers with asterisks.
2063 - *
2064 - * @param string $cc_payload the raw CC details from payment logs.
2065 - * @param array $remote_cc_data associative array of decoded CC data.
2066 - *
2067 - * @return array associative or empty array.
2068 - *
2069 - * @since 1.16.10 (J) - 1.6.10 (WP) moved from widget Virtual Terminal.
2070 - */
2071 - protected function parseCreditCardValuePairs($cc_payload, array $remote_cc_data = [])
2072 - {
2073 - $cc_value_pairs = [];
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 = [];
2074 2529
2075 - if (empty($cc_payload)) {
2076 - return $cc_value_pairs;
2077 - }
2530 + if (empty($cc_payload)) {
2531 + return $cc_value_pairs;
2532 + }
2078 2533
2079 - $cc_lines = preg_split("/(\r\n|\n|\r)/", $cc_payload);
2534 + $cc_lines = preg_split("/(\r\n|\n|\r)/", $cc_payload);
2080 2535
2081 - foreach ($cc_lines as $cc_line) {
2082 - if (strpos($cc_line, ':') === false) {
2083 - continue;
2084 - }
2536 + foreach ($cc_lines as $cc_line) {
2537 + if (strpos($cc_line, ':') === false) {
2538 + continue;
2539 + }
2085 2540
2086 - $cc_line_parts = explode(':', $cc_line);
2541 + $cc_line_parts = explode(':', $cc_line);
2087 2542
2088 - if (empty($cc_line_parts[0]) || !strlen(trim($cc_line_parts[1]))) {
2089 - continue;
2090 - }
2543 + if (empty($cc_line_parts[0]) || !strlen(trim($cc_line_parts[1]))) {
2544 + continue;
2545 + }
2091 2546
2092 - $key = str_replace(' ', '_', strtolower($cc_line_parts[0]));
2093 - $value = trim($cc_line_parts[1]);
2547 + $key = str_replace(' ', '_', strtolower($cc_line_parts[0]));
2548 + $value = trim($cc_line_parts[1]);
2094 2549
2095 - if (isset($cc_value_pairs[$key])) {
2096 - /**
2097 - * Do not overwrite existing keys because this probably means that the
2098 - * credit card was updated by an OTA like Booking.com, hence the payment
2099 - * logs string in VBO may contain the information of two different cards.
2100 - * New credit card details are always pre-pended by VCM in the payment logs.
2101 - */
2102 - continue;
2103 - }
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 + }
2104 2559
2105 - if (!empty($remote_cc_data[$key]) && is_string($remote_cc_data[$key]) && strpos($value, '*') !== false) {
2106 - // replace masked numbers with remote content
2107 - $value = $this->replaceMaskedNumbers($value, $remote_cc_data[$key]);
2108 - }
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 + }
2109 2564
2110 - $cc_value_pairs[$key] = $value;
2111 - }
2565 + $cc_value_pairs[$key] = $value;
2566 + }
2112 2567
2113 - return $cc_value_pairs;
2114 - }
2568 + return $cc_value_pairs;
2569 + }
2115 2570
2116 - /**
2117 - * Given a local and a remote credit card number string with
2118 - * masked symbols, replaces the values in the corresponding
2119 - * positions with the unmasked numbers.
2120 - *
2121 - * @param string $local current string with masked values.
2122 - * @param string $remote remote string with unmasked values.
2123 - *
2124 - * @return string the local string with unmasked values.
2125 - *
2126 - * @since 1.16.10 (J) - 1.6.10 (WP) moved from widget Virtual Terminal.
2127 - */
2128 - protected function replaceMaskedNumbers($local, $remote)
2129 - {
2130 - // split anything but numbers
2131 - $numbers = preg_split("/([^0-9]+)/", trim($remote));
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));
2132 2587
2133 - if ($numbers) {
2134 - // filter empty values
2135 - $numbers = array_filter($numbers);
2136 - }
2588 + if ($numbers) {
2589 + // filter empty values
2590 + $numbers = array_filter($numbers);
2591 + }
2137 2592
2138 - if (!$numbers) {
2139 - // unable to proceed
2140 - return $local;
2141 - }
2593 + if (!$numbers) {
2594 + // unable to proceed
2595 + return $local;
2596 + }
2142 2597
2143 - // split anything but stars (asterisks)
2144 - $stars = preg_split("/([^\*]+)/", trim($local));
2598 + // split anything but stars (asterisks)
2599 + $stars = preg_split("/([^\*]+)/", trim($local));
2145 2600
2146 - if ($stars) {
2147 - // filter empty values
2148 - $stars = array_filter($stars);
2149 - }
2601 + if ($stars) {
2602 + // filter empty values
2603 + $stars = array_filter($stars);
2604 + }
2150 2605
2151 - if (!$stars) {
2152 - // unable to proceed
2153 - return $local;
2154 - }
2606 + if (!$stars) {
2607 + // unable to proceed
2608 + return $local;
2609 + }
2155 2610
2156 - // replace masked symbols with numbers at their first occurrence
2157 - foreach ($numbers as $k => $unmasked) {
2158 - if (!isset($stars[$k])) {
2159 - continue;
2160 - }
2611 + // replace masked symbols with numbers at their first occurrence
2612 + foreach ($numbers as $k => $unmasked) {
2613 + if (!isset($stars[$k])) {
2614 + continue;
2615 + }
2161 2616
2162 - $masked_pos = strpos($local, $stars[$k]);
2617 + $masked_pos = strpos($local, $stars[$k]);
2163 2618
2164 - if ($masked_pos === false) {
2165 - continue;
2166 - }
2619 + if ($masked_pos === false) {
2620 + continue;
2621 + }
2167 2622
2168 - $local = substr_replace($local, $unmasked, $masked_pos, strlen($stars[$k]));
2169 - }
2623 + $local = substr_replace($local, $unmasked, $masked_pos, strlen($stars[$k]));
2624 + }
2170 2625
2171 - // return the string with possibly unmasked values
2172 - return $local;
2173 - }
2626 + // return the string with possibly unmasked values
2627 + return $local;
2628 + }
2174 2629
2175 - /**
2176 - * Tells whether the booking can be created. By default this
2177 - * is only allowed from the administrator section of the site.
2178 - *
2179 - * @return bool
2180 - */
2181 - protected function canCreate()
2182 - {
2183 - return $this->get('_isAdministrator') || JFactory::getApplication()->isClient('administrator');
2184 - }
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 + }
2185 2640
2186 - /**
2187 - * Gets and sets the tariff ID if a rate plan was set.
2188 - *
2189 - * @return int the tariff ID or 0.
2190 - */
2191 - protected function loadTariffID()
2192 - {
2193 - $dbo = JFactory::getDbo();
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();
2194 2653
2195 - $id_tariff = 0;
2196 - $room = $this->getRoom();
2197 - $daysdiff = (int)$this->get('nights', 1);
2654 + $id_tariff = 0;
2198 2655
2199 - 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', [])) {
2200 - $room['id_price'] = (int)$room['id_price'];
2656 + // access current room data
2657 + $room = $roomData ?: $this->getRoom();
2201 2658
2202 - $q = "SELECT `id` FROM `#__vikbooking_dispcost` WHERE `idroom`={$room['id']} AND `days`={$daysdiff} AND `idprice`={$room['id_price']};";
2203 - $dbo->setQuery($q);
2204 - $id_tariff = $dbo->loadResult();
2205 - }
2659 + // current nights of stay
2660 + $daysdiff = (int) (($room['nights'] ?? 0) ?: $this->get('nights', 1));
2206 2661
2207 - $this->set('id_tariff', (int)$id_tariff);
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 + );
2208 2673
2209 - return (int)$id_tariff;
2210 - }
2674 + $id_tariff = (int) $dbo->loadResult();
2675 + }
2211 2676
2212 - /**
2213 - * Applies the turnover time to the checkout timestamp and sets its value.
2214 - *
2215 - * @return int the turnover seconds applied.
2216 - */
2217 - protected function applyTurnover()
2218 - {
2219 - $turnover_secs = 0;
2220 - $checkout = $this->get('checkout', 0);
2677 + // set current tariff ID
2678 + $this->set('id_tariff', $id_tariff);
2221 2679
2222 - if ($checkout) {
2223 - // turnover time
2224 - $turnover_secs = VikBooking::getHoursRoomAvail() * 3600;
2680 + // return the current tariff ID
2681 + return $id_tariff;
2682 + }
2225 2683
2226 - $this->set('checkout_real', ($checkout + $turnover_secs));
2227 - }
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);
2228 2693
2229 - $this->set('turnover_secs', $turnover_secs);
2694 + if ($checkout) {
2695 + // turnover time
2696 + $turnover_secs = VikBooking::getHoursRoomAvail() * 3600;
2230 2697
2231 - return $turnover_secs;
2232 - }
2698 + $this->set('checkout_real', ($checkout + $turnover_secs));
2699 + }
2233 2700
2234 - /**
2235 - * Returns the details of a specific room ID.
2236 - *
2237 - * @param int $rid the room ID.
2238 - *
2239 - * @return array the record found or empty array.
2240 - */
2241 - protected function getRoomDetails($rid = null)
2242 - {
2243 - $all_rooms = VikBooking::getAvailabilityInstance(true)->loadRooms();
2701 + $this->set('turnover_secs', $turnover_secs);
2244 2702
2245 - if (!$rid) {
2246 - $inj_room = $this->getRoom();
2247 - if (!empty($inj_room['id'])) {
2248 - $rid = $inj_room['id'];
2249 - }
2250 - }
2703 + return $turnover_secs;
2704 + }
2251 2705
2252 - if ($rid && isset($all_rooms[$rid])) {
2253 - return $all_rooms[$rid];
2254 - }
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 + }
2255 2719
2256 - return [];
2257 - }
2720 + return $this->allRooms;
2721 + }
2258 2722
2259 - /**
2260 - * Gets the list of rooms involved in the reservation in case of closures.
2261 - *
2262 - * @return array the list of rooms involved
2263 - */
2264 - protected function getRoomsPool()
2265 - {
2266 - $room = $this->getRoom();
2267 - if (empty($room['id'])) {
2268 - return [];
2269 - }
2270 - $room = $this->getRoomDetails($room['id']);
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();
2271 2733
2272 - // gather values
2273 - $set_close_others = (array)$this->get('close_others', []);
2274 - $split_stay_data = $this->get('split_stay', []);
2275 - $set_closed = (int)$this->get('set_closed');
2276 - $turnover_secs = $this->get('turnover_secs', 0);
2277 - $hcheckin = $this->get('checkin_h', 12);
2278 - $mcheckin = $this->get('checkin_m', 0);
2279 - $hcheckout = $this->get('checkout_h', 10);
2280 - $mcheckout = $this->get('checkout_m', 0);
2734 + if (!$rid) {
2735 + $inj_room = $this->getRoom();
2736 + $rid = $inj_room['id'] ?? 0;
2737 + }
2281 2738
2282 - $av_helper = VikBooking::getAvailabilityInstance(true);
2283 - $all_rooms = $av_helper->loadRooms();
2284 - $rooms_pool = [];
2285 - $closeothers = [];
2739 + if ($rid && isset($all_rooms[$rid])) {
2740 + return $all_rooms[$rid];
2741 + }
2286 2742
2287 - if ($set_close_others && $set_closed) {
2288 - // prepend current room for closing
2289 - array_unshift($set_close_others, $room['id']);
2290 - }
2291 - $set_close_others = array_unique($set_close_others);
2743 + return [];
2744 + }
2292 2745
2293 - foreach ($set_close_others as $closeid) {
2294 - if (empty($closeid)) {
2295 - continue;
2296 - }
2297 - if ((int)$closeid === -1) {
2298 - // close all rooms
2299 - $closeothers = [];
2300 - foreach ($all_rooms as $cr) {
2301 - array_push($closeothers, $cr);
2302 - }
2303 - break;
2304 - }
2305 - foreach ($all_rooms as $cr) {
2306 - if ((int)$cr['id'] == (int)$closeid) {
2307 - // push the main room or one of the other rooms requested for closure
2308 - array_push($closeothers, $cr);
2309 - break;
2310 - }
2311 - }
2312 - }
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();
2313 2757
2314 - if (!$closeothers || !$set_closed) {
2315 - $rooms_pool = [$room];
2316 - } else {
2317 - $rooms_pool = $closeothers;
2318 - }
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']);
2319 2764
2320 - // check split stay rooms booking
2321 - if (!empty($split_stay_data)) {
2322 - // reset pool and set it with the split stay rooms
2323 - $rooms_pool = [];
2324 - foreach ($split_stay_data as $sps_k => $split_stay) {
2325 - if (!isset($all_rooms[$split_stay['idroom']])) {
2326 - continue;
2327 - }
2328 - // calculate and set the exact check-in and check-out timestamps for this split-room
2329 - $split_stay['checkin_ts'] = VikBooking::getDateTimestamp($split_stay['checkin'], $hcheckin, $mcheckin);
2330 - $split_stay['checkout_ts'] = VikBooking::getDateTimestamp($split_stay['checkout'], $hcheckout, $mcheckout);
2331 - $split_stay['realback_ts'] = $turnover_secs + $split_stay['checkout_ts'];
2332 - $split_stay['nights'] = $av_helper->countNightsOfStay($split_stay['checkin_ts'], $split_stay['checkout_ts']);
2333 - $split_stay_data[$sps_k] = $split_stay;
2334 - // push room data to pool after storing additional information
2335 - $room_data = $all_rooms[$split_stay['idroom']];
2336 - $room_data['checkin_ts'] = $split_stay['checkin_ts'];
2337 - $room_data['checkout_ts'] = $split_stay['checkout_ts'];
2338 - $rooms_pool[] = $room_data;
2339 - }
2340 - if (!$rooms_pool) {
2341 - $this->setError('No valid rooms for the split stay booking');
2342 - return [];
2343 - }
2344 - // update split stay data manipulated
2345 - $this->set('split_stay', $split_stay_data);
2346 - }
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);
2347 2774
2348 - return $rooms_pool;
2349 - }
2775 + // build containers
2776 + $rooms_pool = [];
2777 + $closeothers = [];
2350 2778
2351 - /**
2352 - * Checks that the room is available on the requested dates.
2353 - * Call this method only after getting the rooms pool.
2354 - *
2355 - * @return bool
2356 - */
2357 - protected function isRoomAvailable()
2358 - {
2359 - $inj_room = $this->getRoom();
2360 - if (empty($inj_room['id'])) {
2361 - return false;
2362 - }
2363 - $room = $this->getRoomDetails($inj_room['id']);
2364 - if (!$room) {
2365 - return false;
2366 - }
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);
2367 2784
2368 - $split_stay_data = $this->get('split_stay', []);
2369 - $set_closed = $this->get('set_closed', 0);
2370 - $num_rooms = $this->get('num_rooms', 1);
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 + }
2371 2805
2372 - $room_available = true;
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 + }
2373 2822
2374 - if (empty($split_stay_data)) {
2375 - // make sure the rooms are available
2376 - $check_units = $room['units'];
2377 - if ($num_rooms > 1 && $num_rooms <= $room['units'] && !$set_closed) {
2378 - // only when non closing the room we check the availability for the units requested for booking
2379 - $check_units = $room['units'] - $num_rooms + 1;
2380 - }
2381 - $room_available = VikBooking::roomBookable($room['id'], $check_units, $this->get('checkin', 0), $this->get('checkout', 0));
2382 - } else {
2383 - $all_rooms = VikBooking::getAvailabilityInstance(true)->loadRooms();
2384 - // make sure the rooms for the split stay are available
2385 - foreach ($split_stay_data as $split_stay) {
2386 - if (!isset($all_rooms[$split_stay['idroom']])) {
2387 - $room_available = false;
2388 - break;
2389 - }
2390 - $room_available = $room_available && VikBooking::roomBookable($split_stay['idroom'], $all_rooms[$split_stay['idroom']]['units'], $split_stay['checkin_ts'], $split_stay['checkout_ts']);
2391 - }
2392 - }
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 + }
2393 2850
2394 - return $room_available;
2395 - }
2851 + return $rooms_pool;
2852 + }
2396 2853
2397 - /**
2398 - * In case the reservation is forced or is a closure, we detect the
2399 - * forced reason to eventually attach it to the booking history.
2400 - *
2401 - * @param bool $room_available whether the room is available.
2402 - *
2403 - * @return void
2404 - */
2405 - protected function detectForcedReason($room_available = true)
2406 - {
2407 - $split_stay_data = $this->get('split_stay', []);
2408 - $force_booking = $this->get('force_booking', 0);
2409 - $set_closed = $this->get('set_closed', 0);
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 + }
2410 2869
2411 - $forced_reason = $this->get('forced_reason', '');
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);
2412 2877
2413 - if (empty($split_stay_data)) {
2414 - // eventually build string for the description of the history event
2415 - if (($force_booking || $set_closed) && !$room_available) {
2416 - $forced_reason = JText::translate('VBO_FORCED_BOOKDATES');
2417 - }
2418 - } else {
2419 - $all_rooms = VikBooking::getAvailabilityInstance(true)->loadRooms();
2420 - // set "split stay" as the description of the history event
2421 - $forced_reason = JText::translate('VBO_SPLIT_STAY') . "\n";
2422 - foreach ($split_stay_data as $sps_k => $split_stay) {
2423 - // describe the split stay for each room
2424 - if (!isset($all_rooms[$split_stay['idroom']])) {
2425 - continue;
2426 - }
2427 - $room_stay_nights = $split_stay['nights'];
2428 - $forced_reason .= $all_rooms[$split_stay['idroom']]['name'] . ': ' . $room_stay_nights . ' ' . ($room_stay_nights > 1 ? JText::translate('VBDAYS') : JText::translate('VBDAY')) . ', ';
2429 - $forced_reason .= $split_stay['checkin'] . ' - ' . $split_stay['checkout'] . "\n";
2430 - }
2431 - $forced_reason = rtrim($forced_reason, "\n");
2432 - }
2878 + // determine availability
2879 + $rooms_available = true;
2433 2880
2434 - $this->set('forced_reason', $forced_reason);
2435 - }
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 + }
2436 2950
2437 - /**
2438 - * Stores the customer information to a new or existing record.
2439 - * In case of success, the customer ID property is updated.
2440 - * The customer shall be stored before the reservation records.
2441 - *
2442 - * @return bool
2443 - */
2444 - protected function storeCustomer()
2445 - {
2446 - $dbo = JFactory::getDbo();
2951 + return $rooms_available;
2952 + }
2447 2953
2448 - $inj_customer = $this->getCustomer();
2449 - $first_name = !empty($inj_customer['first_name']) ? $inj_customer['first_name'] : '';
2450 - $last_name = !empty($inj_customer['last_name']) ? $inj_customer['last_name'] : '';
2451 - $custdata = !empty($inj_customer['data']) ? $inj_customer['data'] : '';
2452 - $email = !empty($inj_customer['email']) ? $inj_customer['email'] : '';
2453 - $country = !empty($inj_customer['country']) ? $inj_customer['country'] : '';
2454 - $phone = !empty($inj_customer['phone']) ? $inj_customer['phone'] : '';
2455 - $gender = !empty($inj_customer['gender']) ? $inj_customer['gender'] : '';
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);
2456 2967
2457 - // custom fields
2458 - $q = "SELECT * FROM `#__vikbooking_custfields` ORDER BY `ordering` ASC;";
2459 - $dbo->setQuery($q);
2460 - $all_cfields = $dbo->loadAssocList();
2968 + $forced_reason = $this->get('forced_reason', '');
2461 2969
2462 - $customer_cfields = [];
2463 - $customer_extrainfo = [];
2464 - $custdata_parts = explode("\n", $custdata);
2465 - foreach ($custdata_parts as $cdataline) {
2466 - if (!strlen(trim($cdataline))) {
2467 - continue;
2468 - }
2469 - $cdata_parts = explode(':', $cdataline);
2470 - if (count($cdata_parts) < 2 || !strlen(trim($cdata_parts[0])) || !strlen(trim($cdata_parts[1]))) {
2471 - continue;
2472 - }
2473 - foreach ($all_cfields as $cf) {
2474 - $needle = JText::translate($cf['name']);
2475 - if (!empty($needle) && strpos($cdata_parts[0], $needle) !== false && !array_key_exists($cf['id'], $customer_cfields) && $cf['type'] != 'country') {
2476 - $user_input_val = trim($cdata_parts[1]);
2477 - $customer_cfields[$cf['id']] = $user_input_val;
2478 - if (!empty($cf['flag'])) {
2479 - $customer_extrainfo[$cf['flag']] = $user_input_val;
2480 - } elseif ($cf['type'] == 'state') {
2481 - $customer_extrainfo['state'] = $user_input_val;
2482 - }
2483 - break;
2484 - }
2485 - }
2486 - }
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 + }
2487 2990
2488 - if (!empty($gender) && in_array(strtoupper((string) $gender), ['M', 'F'])) {
2489 - // inject the customer gender value
2490 - $customer_extrainfo['gender'] = strtoupper($gender);
2491 - }
2991 + $this->set('forced_reason', $forced_reason);
2992 + }
2492 2993
2493 - $cpin = VikBooking::getCPinInstance();
2494 - $cpin->is_admin = true;
2495 - $cpin->setCustomerExtraInfo($customer_extrainfo);
2496 - $cpin->saveCustomerDetails($first_name, $last_name, $email, $phone, $country, $customer_cfields);
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();
2497 3004
2498 - $customer_id = $cpin->getNewCustomerId();
2499 - if (!$customer_id) {
2500 - return false;
2501 - }
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'] : '';
2502 3016
2503 - $inj_customer['id'] = $customer_id;
2504 - $this->setCustomer($inj_customer);
3017 + // custom fields
3018 + $q = "SELECT * FROM `#__vikbooking_custfields` ORDER BY `ordering` ASC;";
3019 + $dbo->setQuery($q);
3020 + $all_cfields = $dbo->loadAssocList();
2505 3021
2506 - return true;
2507 - }
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 + }
2508 3047
2509 - /**
2510 - * Returns the calculated total booking amount and total taxes.
2511 - * Sets the necessary properties with the calculated amounts.
2512 - *
2513 - * @return array list of booking total amount and total taxes.
2514 - */
2515 - protected function calculateTotal()
2516 - {
2517 - // the values to calculate
2518 - $set_total = 0;
2519 - $set_taxes = 0;
3048 + if (!empty($gender) && in_array(strtoupper((string) $gender), ['M', 'F'])) {
3049 + // inject the customer gender value
3050 + $customer_extrainfo['gender'] = strtoupper($gender);
3051 + }
2520 3052
2521 - $dbo = JFactory::getDbo();
3053 + if (!empty($address)) {
3054 + // inject extra info
3055 + $customer_extrainfo['address'] = strlen((string) $address) > 200 ? substr($address, 0, 200) : $address;
3056 + }
2522 3057
2523 - // get data
2524 - $inj_room = $this->getRoom();
2525 - $set_closed = $this->get('set_closed', 0);
2526 - $daysdiff = (int)$this->get('nights', 1);
2527 - $num_rooms = (int)$this->get('num_rooms', 1);
2528 - $totalpnight = !empty($inj_room['total_or_pnight']) ? $inj_room['total_or_pnight'] : 'total';
2529 - $cust_cost = !empty($inj_room['cust_cost']) ? (float)$inj_room['cust_cost'] : 0;
2530 - $room_cost = !empty($inj_room['room_cost']) ? (float)$inj_room['room_cost'] : 0;
2531 - $id_price = !empty($inj_room['id_price']) ? (int)$inj_room['id_price'] : 0;
2532 - $id_tax = !empty($inj_room['id_tax']) ? (int)$inj_room['id_tax'] : 0;
3058 + if (!empty($city)) {
3059 + // inject extra info
3060 + $customer_extrainfo['city'] = strlen((string) $city) > 50 ? substr($city, 0, 50) : $city;
3061 + }
2533 3062
2534 - $split_stay_data = $this->get('split_stay', []);
3063 + if (!empty($zip)) {
3064 + // inject extra info
3065 + $customer_extrainfo['zip'] = strlen((string) $zip) > 10 ? substr($zip, 0, 10) : $zip;
3066 + }
2535 3067
2536 - $room = $this->getRoomDetails();
2537 - if (!$room) {
2538 - return [$set_total, $set_taxes];
2539 - }
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);
2540 3072
2541 - if ($cust_cost > 0 && !$set_closed) {
2542 - // custom cost can be per night
2543 - if ($totalpnight == 'pnight') {
2544 - $cust_cost = $cust_cost * $daysdiff;
2545 - }
2546 - $set_total = $cust_cost;
3073 + $customer_id = $cpin->getNewCustomerId();
3074 + if (!$customer_id) {
3075 + return false;
3076 + }
2547 3077
2548 - if (!$id_tax && ($inj_room['guess_tax'] ?? false)) {
2549 - // try to guess the tax rate
2550 - $dbo->setQuery(
2551 - $dbo->getQuery(true)
2552 - ->select($dbo->qn('id'))
2553 - ->from($dbo->qn('#__vikbooking_iva'))
2554 - ->order($dbo->qn('aliq') . ' ASC'),
2555 - 0, 1);
2556 - $guessed_id_tax = $dbo->loadResult();
2557 - if ($guessed_id_tax) {
2558 - // update the id_tax values
2559 - $id_tax = $guessed_id_tax;
2560 - $inj_room['id_tax'] = $guessed_id_tax;
2561 - $this->setRoom($inj_room);
2562 - }
2563 - }
3078 + $inj_customer['id'] = $customer_id;
3079 + $this->setCustomer($inj_customer);
2564 3080
2565 - // apply taxes, if necessary
2566 - if ($id_tax) {
2567 - $dbo->setQuery(
2568 - $dbo->getQuery(true)
2569 - ->select([
2570 - $dbo->qn('i.aliq'),
2571 - $dbo->qn('i.taxcap'),
2572 - ])
2573 - ->from($dbo->qn('#__vikbooking_iva', 'i'))
2574 - ->where($dbo->qn('i.id') . ' = ' . (int) $id_tax),
2575 - 0, 1);
2576 - $taxdata = $dbo->loadAssoc();
2577 - if ($taxdata) {
2578 - $aliq = $taxdata['aliq'];
2579 - if (floatval($aliq) > 0.00) {
2580 - if (!VikBooking::ivaInclusa()) {
2581 - // add tax to the total amount
2582 - $subt = 100 + (float)$aliq;
2583 - $set_total = ($set_total * $subt / 100);
2584 - /**
2585 - * Tax Cap implementation for prices tax excluded (most common).
2586 - *
2587 - * @since 1.12 (J) - 1.2 (WP)
2588 - */
2589 - if ($taxdata['taxcap'] > 0 && ($set_total - $cust_cost) > $taxdata['taxcap']) {
2590 - $set_total = ($cust_cost + $taxdata['taxcap']);
2591 - }
2592 - // calculate tax
2593 - $set_taxes = $set_total - $cust_cost;
2594 - } else {
2595 - // calculate tax
2596 - $cost_minus_tax = VikBooking::sayPackageMinusIva($cust_cost, $id_tax);
2597 - $set_taxes += ($cust_cost - $cost_minus_tax);
2598 - }
2599 - }
2600 - }
2601 - }
2602 - } elseif (!empty($id_price) && $room_cost > 0 && !$set_closed && empty($split_stay_data)) {
2603 - // one website rate plan was selected, so we calculate total and taxes
2604 - $set_total = $room_cost;
3081 + return true;
3082 + }
2605 3083
2606 - // find tax rate assigned to this rate plan
2607 - $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 . ";";
2608 - $dbo->setQuery($q);
2609 - $taxdata = $dbo->loadAssoc();
2610 - if ($taxdata) {
2611 - $aliq = $taxdata['aliq'];
2612 - if (floatval($aliq) > 0.00) {
2613 - if (!VikBooking::ivaInclusa()) {
2614 - // add tax to the total amount
2615 - $subt = 100 + (float)$aliq;
2616 - $set_total = ($set_total * $subt / 100);
2617 - /**
2618 - * Tax Cap implementation for prices tax excluded (most common).
2619 - *
2620 - * @since 1.12 (J) - 1.2 (WP)
2621 - */
2622 - if ($taxdata['taxcap'] > 0 && ($set_total - $room_cost) > $taxdata['taxcap']) {
2623 - $set_total = ($room_cost + $taxdata['taxcap']);
2624 - }
2625 - // calculate tax
2626 - $set_taxes = $set_total - $room_cost;
2627 - } else {
2628 - // calculate tax
2629 - $cost_minus_tax = VikBooking::sayPackageMinusIva($room_cost, $taxdata['idiva']);
2630 - $set_taxes += ($room_cost - $cost_minus_tax);
2631 - }
2632 - }
2633 - }
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();
2634 3093
2635 - // total and taxes should be multiplied by the number of rooms booked when using a website rate plan
2636 - if ($set_closed) {
2637 - $set_total *= $room['units'];
2638 - $set_taxes *= $room['units'];
2639 - } elseif ($num_rooms > 1 && $num_rooms <= $room['units']) {
2640 - $set_total *= $num_rooms;
2641 - $set_taxes *= $num_rooms;
2642 - }
2643 - }
3094 + // the values to calculate
3095 + $set_total = 0;
3096 + $set_taxes = 0;
3097 + $set_city_tax = 0;
3098 + $set_fees = 0;
2644 3099
2645 - // set values
2646 - $this->set('_total', $set_total);
2647 - $this->set('_total_tax', $set_taxes);
3100 + // access all rooms booking data
3101 + $roomsData = $this->getRooms();
2648 3102
2649 - return [$set_total, $set_taxes];
2650 - }
3103 + if (!$roomsData) {
3104 + return [$set_total, $set_taxes];
3105 + }
2651 3106
2652 - /**
2653 - * Stores the booking and room-booking records.
2654 - * If no errors, the newly generated booking id is set.
2655 - *
2656 - * @param array $rooms_pool list of rooms involved.
2657 - *
2658 - * @return bool
2659 - */
2660 - protected function storeReservationRecords(array $rooms_pool)
2661 - {
2662 - $dbo = JFactory::getDbo();
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', []);
2663 3112
2664 - $set_closed = $this->get('set_closed', 0);
2665 - $units_closed = $this->get('units_closed', 0);
2666 - $daysdiff = (int) $this->get('nights', 1);
2667 - $num_rooms = (int) $this->get('num_rooms', 1);
2668 - $adults = (int) $this->get('adults', 1);
2669 - $children = (int) $this->get('children', 0);
2670 - $children_age = (array) $this->get('children_age', []);
2671 - $status = $this->get('status', 'confirmed');
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;
2672 3122
2673 - $split_stay_data = $this->get('split_stay', []);
2674 - $room = $this->getRoomDetails();
2675 - if (!$room || !$rooms_pool) {
2676 - return false;
2677 - }
3123 + // access room details
3124 + $roomDetails = $this->getRoomDetails($roomData['id'] ?? 0);
2678 3125
2679 - // get current Joomla/WordPress User ID
2680 - $now_user = JFactory::getUser();
2681 - $store_ujid = property_exists($now_user, 'id') ? (int)$now_user->id : 0;
3126 + // check for stay dates at room level
3127 + $roomNightsStay = ($roomData['nights'] ?? 0) ?: $daysdiff;
2682 3128
2683 - // forced booking reason, status validation and additional data
2684 - $forced_reason = $this->get('forced_reason', '');
2685 - $valid_statuses = ['confirmed', 'standby'];
2686 - $status = in_array($status, $valid_statuses) ? $status : 'confirmed';
2687 - $paymentmeth = $this->get('id_payment', '');
2688 - $auto_paymeth = (bool) $this->get('auto_payment_method', false);
2689 - $set_total = (float) $this->get('_total', 0);
2690 - $set_taxes = (float) $this->get('_total_tax', 0);
3129 + // calculate room totals
3130 + $roomTotalValue = 0;
3131 + $roomTotalTax = 0;
3132 + $roomTotalCityTax = 0;
3133 + $roomTotalFees = 0;
2691 3134
2692 - // booking creation date
2693 - $now_ts = time();
2694 - if ($created_on = $this->get('created_on')) {
2695 - if (is_int($created_on)) {
2696 - // timestamp expected
2697 - $now_ts = $created_on;
2698 - } elseif (preg_match('/^[0-9]{4}\-[0-9]{2}\-[0-9]{2}/', (string) $created_on)) {
2699 - // date in military format expected, with or without the time
2700 - $now_ts = strtotime($created_on);
2701 - }
2702 - }
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;
2703 3141
2704 - // stay dates
2705 - $checkin_ts = $this->get('checkin');
2706 - $checkout_ts = $this->get('checkout');
2707 - $realback_ts = $this->get('checkout_real', $checkout_ts);
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 + }
2708 3158
2709 - // room
2710 - $inj_room = $this->getRoom();
2711 - $cust_cost = !empty($inj_room['cust_cost']) ? (float) $inj_room['cust_cost'] : 0;
2712 - $room_cost = !empty($inj_room['room_cost']) ? (float) $inj_room['room_cost'] : 0;
2713 - $id_price = !empty($inj_room['id_price']) ? (int) $inj_room['id_price'] : 0;
2714 - $id_tax = !empty($inj_room['id_tax']) ? (int) $inj_room['id_tax'] : 0;
2715 - $id_tariff = $this->get('id_tariff', 0);
2716 - $cust_indexes = !empty($inj_room['cust_indexes']) ? (array) $inj_room['cust_indexes'] : [];
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;
2717 3188
2718 - // custom rate modifier per night
2719 - $totalpnight = !empty($inj_room['total_or_pnight']) ? $inj_room['total_or_pnight'] : 'total';
2720 - if ($cust_cost > 0.00 && !$set_closed && $totalpnight == 'pnight') {
2721 - $cust_cost = $cust_cost * $daysdiff;
2722 - }
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 + }
2723 3213
2724 - // customer
2725 - $cpin = VikBooking::getCPinInstance();
2726 - $inj_customer = $this->getCustomer();
2727 - $customer_id = !empty($inj_customer['id']) ? $inj_customer['id'] : 0;
2728 - $customer_pin = !empty($inj_customer['pin']) ? $inj_customer['pin'] : '';
2729 - $t_first_name = !empty($inj_customer['first_name']) ? $inj_customer['first_name'] : '';
2730 - $t_last_name = !empty($inj_customer['last_name']) ? $inj_customer['last_name'] : '';
2731 - $customer_data = !empty($inj_customer['data']) ? $inj_customer['data'] : '';
2732 - $customer_email = !empty($inj_customer['email']) ? $inj_customer['email'] : '';
2733 - $country_code = !empty($inj_customer['country']) ? $inj_customer['country'] : '';
2734 - $phone_number = !empty($inj_customer['phone']) ? $inj_customer['phone'] : '';
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 + }
2735 3223
2736 - if ($set_closed) {
2737 - // get the possibly custom "customer notes"
2738 - $customer_notes = $customer_data;
2739 - // overwrite "customer notes" to "Room Closed"
2740 - $customer_data = JText::translate('VBDBTEXTROOMCLOSED');
2741 - if ($customer_notes != $customer_data) {
2742 - // append the custom "custom notes" to the administrator notes instead
2743 - $custom_admin_notes = trim($this->get('admin_notes', '') . "\n" . $customer_notes);
2744 - $this->set('admin_notes', $custom_admin_notes);
2745 - }
2746 - }
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 + }
2747 3283
2748 - // check for default customer raw data
2749 - if (!$customer_data && $t_first_name) {
2750 - // build a default raw data string
2751 - $customer_data = "Name: {$t_first_name}\n";
2752 - if ($t_last_name) {
2753 - $customer_data .= "Last Name: {$t_last_name}\n";
2754 - }
2755 - if ($customer_email) {
2756 - $customer_data .= "eMail: {$customer_email}\n";
2757 - }
2758 - if ($country_code) {
2759 - $customer_data .= "Country: {$country_code}\n";
2760 - }
2761 - if ($phone_number) {
2762 - $customer_data .= "Phone: {$phone_number}\n";
2763 - }
2764 - $customer_data = rtrim($customer_data, "\n");
2765 - }
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 + }
2766 3309
2767 - // ensure the country code is in the right format
2768 - if (!empty($country_code)) {
2769 - $country_code = $cpin->get3CharCountry($country_code);
2770 - }
3310 + // increase global totals
3311 + $set_total += $roomTotalValue;
3312 + $set_taxes += $roomTotalTax;
3313 + $set_city_tax += $roomTotalCityTax;
3314 + $set_fees += $roomTotalFees;
3315 + }
2771 3316
2772 - // ensure we have a valid country ISO code
2773 - if (strlen((string) $country_code) > 3) {
2774 - // this looks like an unknown country name
2775 - $country_code = '';
2776 - }
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);
2777 3322
2778 - // generate booking SID
2779 - $sid = VikBooking::getSecretLink();
3323 + // return the list of calculated values
3324 + return [
3325 + $set_total,
3326 + $set_taxes,
3327 + $set_city_tax,
3328 + $set_fees,
3329 + ];
3330 + }
2780 3331
2781 - // assign room specific unit
2782 - $set_room_indexes = !$set_closed ? VikBooking::autoRoomUnit() : false;
2783 - $num_rooms = $num_rooms > 0 ? $num_rooms : 1;
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 + }
2784 3349
2785 - // occupancy and loop limits
2786 - $forend = 1;
2787 - $or_forend = 1;
2788 - $adults_map = [];
2789 - $children_map = [];
2790 - if ($set_closed && empty($split_stay_data)) {
2791 - $forend = $room['units'];
2792 - } elseif ($num_rooms > 1 && $num_rooms <= $room['units'] && empty($split_stay_data)) {
2793 - $forend = $num_rooms;
2794 - $or_forend = $num_rooms;
2795 - // assign adults/children proportionally
2796 - if (($adults + $children) < $num_rooms) {
2797 - // the number of guests does not make much sense but we build the maps anyway
2798 - for ($r = 1; $r <= $or_forend; $r++) {
2799 - $adults_map[$r] = $adults;
2800 - $children_map[$r] = $children;
2801 - }
2802 - } else {
2803 - $adults_per_room = floor(($adults / $num_rooms));
2804 - $adults_left = ($adults % $num_rooms);
2805 - $children_per_room = floor(($children / $num_rooms));
2806 - $children_left = ($children % $num_rooms);
2807 - for ($r = 1; $r <= $or_forend; $r++) {
2808 - $adults_map[$r] = $adults_per_room;
2809 - $children_map[$r] = $children_per_room;
2810 - if ($r == $or_forend) {
2811 - $adults_map[$r] += $adults_left;
2812 - $children_map[$r] += $children_left;
2813 - }
2814 - }
2815 - }
2816 - }
3350 + $dbo = JFactory::getDbo();
2817 3351
2818 - // count total rooms booked
2819 - $totalrooms = ($set_closed && $status == 'confirmed' ? count($rooms_pool) : ($num_rooms > 1 && $num_rooms <= $room['units'] ? $num_rooms : 1));
2820 - $totalrooms = !empty($split_stay_data) ? count($split_stay_data) : $totalrooms;
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');
2821 3361
2822 - // attempt to get the default payment method
2823 - if (!$paymentmeth && ($auto_paymeth || $status == 'standby')) {
2824 - // get the default payment method, if any
2825 - $paymentmeth = $this->getDefaultPaymentMethod($auto_paymeth);
2826 - }
3362 + $split_stay_data = $this->get('split_stay', []);
3363 + $room = $this->getRoomDetails();
3364 + if (!$room || !$rooms_pool) {
3365 + return false;
3366 + }
2827 3367
2828 - // prepare booking record
2829 - $booking = new stdClass;
2830 - $booking->custdata = $customer_data;
2831 - $booking->ts = $now_ts;
2832 - $booking->status = $status;
2833 - $booking->days = $daysdiff;
2834 - $booking->checkin = $checkin_ts;
2835 - $booking->checkout = $checkout_ts;
2836 - $booking->custmail = $customer_email;
2837 - $booking->sid = $sid;
2838 - $booking->idpayment = $paymentmeth;
2839 - $booking->ujid = (int)$store_ujid;
2840 - $booking->roomsnum = $totalrooms;
2841 - $booking->total = $set_total > 0 ? $set_total : null;
2842 - if ($this->get('admin_notes')) {
2843 - $booking->adminnotes = $this->get('admin_notes', '');
2844 - }
2845 - $booking->lang = VikBooking::guessBookingLangFromCountry($country_code);
2846 - $booking->country = $country_code;
2847 - $booking->tot_taxes = $set_taxes > 0 ? $set_taxes : null;
2848 - $booking->phone = $phone_number;
2849 - $booking->closure = ($status == 'standby' ? 0 : ($set_closed || $units_closed ? 1 : 0));
2850 - $booking->split_stay = !empty($split_stay_data) ? 1 : 0;
3368 + // access all rooms data
3369 + $roomsData = $this->getRooms();
2851 3370
2852 - // store reservation
2853 - if ($status == 'confirmed') {
2854 - // occupy the rooms
2855 - $insertedbusy = [];
2856 - if (empty($split_stay_data)) {
2857 - // only when closing other rooms we have an array containing multiple rooms info
2858 - foreach ($rooms_pool as $nowroom) {
2859 - $nowforend = $set_closed ? $nowroom['units'] : $forend;
2860 - for ($b = 1; $b <= $nowforend; $b++) {
2861 - $busy_record = new stdClass;
2862 - $busy_record->idroom = (int)$nowroom['id'];
2863 - $busy_record->checkin = (int)$checkin_ts;
2864 - $busy_record->checkout = (int)$checkout_ts;
2865 - $busy_record->realback = (int)$realback_ts;
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;
2866 3374
2867 - // store busy record
2868 - $dbo->insertObject('#__vikbooking_busy', $busy_record, 'id');
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);
2869 3385
2870 - if (isset($busy_record->id)) {
2871 - $insertedbusy[] = $busy_record->id;
2872 - }
2873 - }
2874 - }
2875 - } else {
2876 - // for split stay bookings we occupy the rooms on the individual stay dates
2877 - foreach ($split_stay_data as $split_stay) {
2878 - $busy_record = new stdClass;
2879 - $busy_record->idroom = (int)$split_stay['idroom'];
2880 - $busy_record->checkin = (int)$split_stay['checkin_ts'];
2881 - $busy_record->checkout = (int)$split_stay['checkout_ts'];
2882 - $busy_record->realback = (int)$split_stay['realback_ts'];
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 + }
2883 3402
2884 - // store busy record
2885 - $dbo->insertObject('#__vikbooking_busy', $busy_record, 'id');
3403 + // stay dates
3404 + $checkin_ts = $this->get('checkin');
3405 + $checkout_ts = $this->get('checkout');
3406 + $realback_ts = $this->get('checkout_real', $checkout_ts);
2886 3407
2887 - if (isset($busy_record->id)) {
2888 - $insertedbusy[] = $busy_record->id;
2889 - }
2890 - }
2891 - }
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) ?: '';
2892 3423
2893 - if (!$insertedbusy) {
2894 - $this->setError('No records were occupied');
2895 - return false;
2896 - }
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 + }
2897 3435
2898 - // store booking record
2899 - $dbo->insertObject('#__vikbooking_orders', $booking, 'id');
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 + }
2900 3465
2901 - if (!isset($booking->id)) {
2902 - $this->setError('Could not store the reservation record');
2903 - return false;
2904 - }
3466 + // ensure the country code is in the right format
3467 + if (!empty($country_code)) {
3468 + $country_code = $cpin->get3CharCountry($country_code);
3469 + }
2905 3470
2906 - // get the newly generated booking ID
2907 - $newoid = $booking->id;
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 + }
2908 3476
2909 - // set the new booking ID
2910 - $this->setNewBookingID($newoid);
3477 + // generate booking SID, unless we are updating
3478 + $sid = !$bookingId ? VikBooking::getSecretLink() : null;
2911 3479
2912 - if (!empty($split_stay_data)) {
2913 - // save transient on db for split stay information
2914 - VBOFactory::getConfig()->set('split_stay_' . $newoid, json_encode($split_stay_data));
2915 - }
3480 + // assign room specific unit
3481 + $set_room_indexes = !$set_closed ? VikBooking::autoRoomUnit() : false;
3482 + $num_rooms = $num_rooms > 0 ? $num_rooms : 1;
2916 3483
2917 - // check if some of the rooms booked have shared calendars
2918 - VikBooking::updateSharedCalendars($newoid, [$room['id']], $checkin_ts, $checkout_ts);
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 + }
2919 3516
2920 - // confirmation number
2921 - $confirmnumber = VikBooking::generateConfirmNumber($newoid, true);
3517 + if ($this->isMultiRoom()) {
3518 + // adjust values
3519 + $forend = 1;
3520 + $or_forend = 1;
3521 + }
2922 3522
2923 - // store busy records/booking relations
2924 - foreach ($insertedbusy as $lid) {
2925 - $obusy_record = new stdClass;
2926 - $obusy_record->idorder = (int)$newoid;
2927 - $obusy_record->idbusy = (int)$lid;
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;
2928 3526
2929 - // store busy relation record
2930 - $dbo->insertObject('#__vikbooking_ordersbusy', $obusy_record, 'id');
2931 - }
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 + }
2932 3535
2933 - // write room records
2934 - foreach ($rooms_pool as $rind => $nowroom) {
2935 - $room_indexes_usemap = [];
2936 - for ($r = 1; $r <= $or_forend; $r++) {
2937 - // Assign room specific unit
2938 - $info_room_avail = [
2939 - 'id' => $newoid,
2940 - 'checkin' => (!empty($nowroom['checkin_ts']) ? $nowroom['checkin_ts'] : $checkin_ts),
2941 - 'checkout' => (!empty($nowroom['checkout_ts']) ? $nowroom['checkout_ts'] : $checkout_ts),
2942 - ];
2943 - $room_indexes = $set_room_indexes === true ? VikBooking::getRoomUnitNumsAvailable($info_room_avail, $nowroom['id']) : [];
2944 - $use_ind_key = 0;
2945 - if ($room_indexes) {
2946 - if (!array_key_exists($nowroom['id'], $room_indexes_usemap)) {
2947 - $room_indexes_usemap[$nowroom['id']] = $use_ind_key;
2948 - } else {
2949 - $use_ind_key = $room_indexes_usemap[$nowroom['id']];
2950 - }
2951 - }
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 + }
2952 3541
2953 - // room custom cost
2954 - $or_cust_cost = $cust_cost > 0.00 ? $cust_cost : 0;
2955 - $or_cust_cost = $or_forend > 1 && $or_cust_cost > 0 ? round(($or_cust_cost / $or_forend), 2) : $or_cust_cost;
2956 - // room cost from website rate plan is always based on one room
2957 - $or_room_cost = $room_cost > 0.00 ? $room_cost : 0;
2958 - if (!empty($split_stay_data) && $cust_cost > 0) {
2959 - // set the average cost per room in case of split stay
2960 - $cost_per_room = ($cust_cost / count($split_stay_data));
2961 - $or_cust_cost = round($cost_per_room, 2);
2962 - if (isset($split_stay_data[$rind]) && isset($split_stay_data[$rind]['nights'])) {
2963 - // count the average cost per room depending on the number of nights of stay
2964 - $cost_per_room = $cust_cost / $daysdiff * $split_stay_data[$rind]['nights'];
2965 - $or_cust_cost = round($cost_per_room, 2);
2966 - }
2967 - }
3542 + // prepare booking record
3543 + $booking = new stdClass;
2968 3544
2969 - // room guests
2970 - $room_adults = isset($adults_map[$r]) && empty($split_stay_data) ? $adults_map[$r] : $adults;
2971 - $room_children = isset($children_map[$r]) && empty($split_stay_data) ? $children_map[$r] : $children;
3545 + if ($bookingId) {
3546 + // inject current booking ID for update
3547 + $booking->id = $bookingId;
3548 + }
2972 3549
2973 - // attempt to gather the children age for this room
2974 - $room_children_age = null;
2975 - if ($room_children && $children_age) {
2976 - $children_age_pool = [];
2977 - for ($ic = 0; $ic < $room_children; $ic++) {
2978 - if (!$children_age) {
2979 - $children_age_pool[] = 0;
2980 - continue;
2981 - }
2982 - // shorten the list and push the current child age
2983 - $current_child_age = array_shift($children_age);
2984 - $children_age_pool[] = (int) $current_child_age;
2985 - }
2986 - $room_children_age = json_encode(['age' => $children_age_pool]);
2987 - }
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;
2988 3583
2989 - // determine room index to use, if any
2990 - $roomindex = null;
2991 - if ($cust_indexes[$use_ind_key] ?? null) {
2992 - // use a custom room index
2993 - $roomindex = (int) $cust_indexes[$use_ind_key];
2994 - } elseif ($room_indexes) {
2995 - $roomindex = (int) $room_indexes[$use_ind_key];
2996 - }
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;
2997 3604
2998 - // store room record
2999 - $room_record = new stdClass;
3000 - $room_record->idorder = (int) $newoid;
3001 - $room_record->idroom = (int) $nowroom['id'];
3002 - $room_record->adults = $room_adults;
3003 - $room_record->children = $room_children;
3004 - $room_record->idtar = !empty($id_tariff) ? $id_tariff : null;
3005 - $room_record->childrenage = $room_children_age;
3006 - $room_record->t_first_name = $t_first_name;
3007 - $room_record->t_last_name = $t_last_name;
3008 - $room_record->roomindex = $roomindex;
3009 - $room_record->cust_cost = $cust_cost > 0.00 ? $or_cust_cost : null;
3010 - $room_record->cust_idiva = $cust_cost > 0.00 && !empty($id_tax) ? $id_tax : null;
3011 - $room_record->room_cost = $room_cost > 0.00 ? $or_room_cost : null;
3605 + // store busy record
3606 + $dbo->insertObject('#__vikbooking_busy', $busy_record, 'id');
3012 3607
3013 - $dbo->insertObject('#__vikbooking_ordersrooms', $room_record, 'id');
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'];
3014 3621
3015 - if (!isset($room_record->id)) {
3016 - $this->setError('Could not store room reservation record for booking ID ' . $room_record->idorder);
3017 - continue;
3018 - }
3622 + // store busy record
3623 + $dbo->insertObject('#__vikbooking_busy', $busy_record, 'id');
3019 3624
3020 - // Assign room specific unit
3021 - if ($room_indexes) {
3022 - $room_indexes_usemap[$nowroom['id']]++;
3023 - }
3024 - }
3025 - }
3026 - } elseif ($status == 'standby') {
3027 - // store booking record
3028 - $dbo->insertObject('#__vikbooking_orders', $booking, 'id');
3625 + if (isset($busy_record->id)) {
3626 + $insertedbusy[] = $busy_record->id;
3627 + }
3628 + }
3629 + }
3029 3630
3030 - if (!isset($booking->id)) {
3031 - $this->setError('Could not store the reservation record');
3032 - return false;
3033 - }
3631 + if (!$insertedbusy) {
3632 + $this->setError('No records were occupied');
3633 + return false;
3634 + }
3635 + }
3034 3636
3035 - // get the newly generated booking ID
3036 - $newoid = $booking->id;
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 + }
3037 3644
3038 - // set the new booking ID
3039 - $this->setNewBookingID($newoid);
3645 + if (empty($booking->id)) {
3646 + $this->setError('Could not save reservation record.');
3647 + return false;
3648 + }
3040 3649
3041 - if (!empty($split_stay_data)) {
3042 - // save transient on db for split stay information
3043 - VBOFactory::getConfig()->set('split_stay_' . $newoid, json_encode($split_stay_data));
3044 - }
3650 + // get the newly generated (or just updated) booking ID
3651 + $newoid = $booking->id;
3045 3652
3046 - // write room records
3047 - foreach ($rooms_pool as $rind => $nowroom) {
3048 - for ($r = 1; $r <= $or_forend; $r++) {
3049 - // room custom cost
3050 - $or_cust_cost = $cust_cost > 0.00 ? $cust_cost : 0;
3051 - $or_cust_cost = $or_forend > 1 && $or_cust_cost > 0 ? round(($or_cust_cost / $or_forend), 2) : $or_cust_cost;
3052 - // room cost from website rate plan is always based on one room
3053 - $or_room_cost = $room_cost > 0.00 ? $room_cost : 0;
3054 - if (!empty($split_stay_data) && $cust_cost > 0) {
3055 - // set the average cost per room in case of split stay
3056 - $cost_per_room = ($cust_cost / count($split_stay_data));
3057 - $or_cust_cost = round($cost_per_room, 2);
3058 - if (isset($split_stay_data[$rind]) && isset($split_stay_data[$rind]['nights'])) {
3059 - // count the average cost per room depending on the number of nights of stay
3060 - $cost_per_room = $cust_cost / $daysdiff * $split_stay_data[$rind]['nights'];
3061 - $or_cust_cost = round($cost_per_room, 2);
3062 - }
3063 - }
3653 + // set the new booking ID
3654 + $this->setNewBookingID($newoid);
3064 3655
3065 - // room guests
3066 - $room_adults = isset($adults_map[$r]) && empty($split_stay_data) ? $adults_map[$r] : $adults;
3067 - $room_children = isset($children_map[$r]) && empty($split_stay_data) ? $children_map[$r] : $children;
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 + }
3068 3660
3069 - // attempt to gather the children age for this room
3070 - $room_children_age = null;
3071 - if ($room_children && $children_age) {
3072 - $children_age_pool = [];
3073 - for ($ic = 0; $ic < $room_children; $ic++) {
3074 - if (!$children_age) {
3075 - $children_age_pool[] = 0;
3076 - continue;
3077 - }
3078 - // shorten the list and push the current child age
3079 - $current_child_age = array_shift($children_age);
3080 - $children_age_pool[] = (int) $current_child_age;
3081 - }
3082 - $room_children_age = json_encode(['age' => $children_age_pool]);
3083 - }
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);
3084 3664
3085 - // store room record
3086 - $room_record = new stdClass;
3087 - $room_record->idorder = (int) $newoid;
3088 - $room_record->idroom = (int) $nowroom['id'];
3089 - $room_record->adults = $room_adults;
3090 - $room_record->children = $room_children;
3091 - $room_record->idtar = !empty($id_tariff) ? $id_tariff : null;
3092 - $room_record->childrenage = $room_children_age;
3093 - $room_record->t_first_name = $t_first_name;
3094 - $room_record->t_last_name = $t_last_name;
3095 - $room_record->cust_cost = $cust_cost > 0.00 ? $or_cust_cost : null;
3096 - $room_record->cust_idiva = $cust_cost > 0.00 && !empty($id_tax) ? $id_tax : null;
3097 - $room_record->room_cost = $room_cost > 0.00 ? $or_room_cost : null;
3665 + if (!$bookingId) {
3666 + // generate and set confirmation number for the newly created confirmed reservation
3667 + $confirmnumber = VikBooking::generateConfirmNumber($newoid, true);
3668 + }
3098 3669
3099 - $dbo->insertObject('#__vikbooking_ordersrooms', $room_record, 'id');
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;
3100 3675
3101 - if (!isset($room_record->id)) {
3102 - $this->setError('Could not store room reservation record for booking ID ' . $room_record->idorder);
3103 - continue;
3104 - }
3676 + // store busy relation record
3677 + $dbo->insertObject('#__vikbooking_ordersbusy', $obusy_record, 'id');
3678 + }
3679 + }
3105 3680
3106 - if (empty($split_stay_data)) {
3107 - // lock room for pending status
3108 - $tmplock_record = new stdClass;
3109 - $tmplock_record->idroom = (int)$room['id'];
3110 - $tmplock_record->checkin = $checkin_ts;
3111 - $tmplock_record->checkout = $checkout_ts;
3112 - $tmplock_record->until = VikBooking::getMinutesLock(true);
3113 - $tmplock_record->realback = $realback_ts;
3114 - $tmplock_record->idorder = (int)$newoid;
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;
3115 3689
3116 - $dbo->insertObject('#__vikbooking_tmplock', $tmplock_record, 'id');
3117 - }
3118 - }
3119 - }
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 + }
3120 3708
3121 - if (!empty($split_stay_data)) {
3122 - // lock rooms for pending status on proper stay dates
3123 - foreach ($split_stay_data as $split_stay) {
3124 - $tmplock_record = new stdClass;
3125 - $tmplock_record->idroom = (int)$split_stay['idroom'];
3126 - $tmplock_record->checkin = (int)$split_stay['checkin_ts'];
3127 - $tmplock_record->checkout = (int)$split_stay['checkout_ts'];
3128 - $tmplock_record->until = VikBooking::getMinutesLock(true);
3129 - $tmplock_record->realback = (int)$split_stay['realback_ts'];
3130 - $tmplock_record->idorder = (int)$newoid;
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);
3131 3722
3132 - $dbo->insertObject('#__vikbooking_tmplock', $tmplock_record, 'id');
3133 - }
3134 - }
3135 - }
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 + }
3136 3738
3137 - $newoid = $this->getNewBookingID();
3138 - if (!$newoid) {
3139 - return false;
3140 - }
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);
3141 3743
3142 - // assign booking to customer
3143 - if (!$cpin->getNewCustomerId() && !empty($customer_id)) {
3144 - $cpin->setNewPin($customer_pin);
3145 - $cpin->setNewCustomerId($customer_id);
3146 - }
3147 - $cpin->saveCustomerBooking($newoid);
3744 + // number of pets per room
3745 + $room_pets = (int) ($roomsData[$rind]['pets'] ?? 0);
3148 3746
3149 - // Booking History
3150 - $history_obj = VikBooking::getBookingHistoryInstance($newoid);
3151 - $forced_reason = !empty($forced_reason) ? " {$forced_reason}" : $forced_reason;
3152 - $caller_id = $now_user->name ? "({$now_user->name})" : '';
3153 - if ($this->getCaller()) {
3154 - $caller_id = '(' . $this->getCaller() . ')';
3155 - if ($this->getHistoryData()) {
3156 - $history_obj->setExtraData($this->getHistoryData());
3157 - }
3158 - }
3159 - $history_obj->store('NB', trim($caller_id . $forced_reason));
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 + }
3160 3762
3161 - if ($status == 'confirmed' || ($status == 'standby' && class_exists('VCMRequestAvailability'))) {
3162 - // Invoke Channel Manager
3163 - $vcm_autosync = VikBooking::vcmAutoUpdate();
3164 - if ($vcm_autosync > 0) {
3165 - $vcm_obj = VikBooking::getVcmInvoker();
3166 - $vcm_obj->setOids([$newoid])->setSyncType('new');
3167 - $sync_result = $vcm_obj->doSync();
3168 - if ($sync_result === false) {
3169 - // set error message
3170 - $vcm_err = $vcm_obj->getError();
3171 - $this->setError(JText::translate('VBCHANNELMANAGERRESULTKO') . (!empty($vcm_err) ? ' - ' . $vcm_err : ''));
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 + }
3172 3773
3173 - // return true because the booking was actually stored
3174 - return true;
3175 - }
3176 - } elseif (is_file(VCM_SITE_PATH . DIRECTORY_SEPARATOR . 'helpers' . DIRECTORY_SEPARATOR . 'synch.vikbooking.php')) {
3177 - // set the necessary action to invoke VCM
3178 - $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']);
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 + }
3179 3800
3180 - $this->setChannelManagerAction(JText::translate('VBCHANNELMANAGERINVOKEASK') . ' <button type="button" class="btn btn-primary" onclick="document.location.href=\'' . $vcm_sync_url . '\';">' . JText::translate('VBCHANNELMANAGERSENDRQ') . '</button>');
3181 - }
3182 - }
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 + }
3183 3815
3184 - if (VikBooking::isAdmin()) {
3185 - /**
3186 - * Trigger event to allow third party plugins to intercept the admin new booking event.
3187 - *
3188 - * @since 1.16.8 (J) - 1.6.8 (WP)
3189 - */
3190 - VBOFactory::getPlatform()->getDispatcher()->trigger('onAfterCreateNewBookingAdmin', [$newoid]);
3191 - }
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;
3192 3834
3193 - return true;
3194 - }
3835 + $dbo->insertObject('#__vikbooking_ordersrooms', $room_record, 'id');
3195 3836
3196 - /**
3197 - * Attempts to find the default payment method to be assigned to a booking.
3198 - *
3199 - * @param bool $auto If true, it was requested to automatically find the best payment method.
3200 - *
3201 - * @return string The payment method string for the reservation "ID=Name", or an empty string.
3202 - *
3203 - * @since 1.17.3 (J) - 1.7.3 (WP)
3204 - */
3205 - protected function getDefaultPaymentMethod($auto = false)
3206 - {
3207 - $dbo = JFactory::getDbo();
3837 + if (empty($room_record->id)) {
3838 + $this->setError('Could not store room reservation record for booking ID ' . $room_record->idorder);
3839 + continue;
3840 + }
3208 3841
3209 - $dbo->setQuery(
3210 - $dbo->getQuery(true)
3211 - ->select('*')
3212 - ->from($dbo->qn('#__vikbooking_gpayments'))
3213 - ->order($dbo->qn('published') . ' DESC')
3214 - ->order($dbo->qn('ordering') . ' ASC')
3215 - ->order($dbo->qn('setconfirmed') . ' ASC')
3216 - ->order($dbo->qn('name') . ' ASC')
3217 - );
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);
3218 3850
3219 - $methods = $dbo->loadAssocList();
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 + }
3220 3855
3221 - if ($auto) {
3222 - // exclude all the offline or unpublished payment methods
3223 - $methods = array_filter($methods, function($method) {
3224 - return !((bool) intval($method['setconfirmed'])) && (bool) intval($method['published']);
3225 - });
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;
3226 3865
3227 - // reset array keys
3228 - $methods = array_values($methods);
3229 - }
3866 + $dbo->insertObject('#__vikbooking_tmplock', $tmplock_record, 'id');
3867 + }
3868 + }
3869 + }
3870 + }
3230 3871
3231 - if ($methods) {
3232 - // default payment method found
3233 - return sprintf('%s=%s', $methods[0]['id'], $methods[0]['name']);
3234 - }
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;
3235 3882
3236 - // nothing was found
3237 - return '';
3238 - }
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 + }
3239 4045 }