PluginProbe
Yatra – Travel Booking & Tour Operator Software / 3.0.13
Yatra – Travel Booking & Tour Operator Software v3.0.13
3.0.15 3.0.14 3.0.14.1 3.0.14.2 3.0.12 3.0.13 3.0.11 3.0.10 3.0.9 3.0.8 3.0.7 3.0.6 3.0.5 3.0.5.1 3.0.4 3.0.3 3.0.2.9 3.0.2.7 3.0.2.8 3.0.2.6 trunk 1.0.0 2.0.0 2.0.1 2.0.10 All 83 releases
yatra / app / Validators / BookingValidator.php

BookingValidator.php in Yatra – Travel Booking & Tour Operator Software 3.0.13, at app/Validators/BookingValidator.php

532 lines 20.9 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 declare(strict_types=1);
4
5 namespace Yatra\Validators;
6
7 use Yatra\Exceptions\ValidationException;
8
9 /**
10 * Booking Validator
11 *
12 * Comprehensive validation for booking data
13 */
14 class BookingValidator
15 {
16 /**
17 * Statuses a booking row may legally hold.
18 *
19 * `pending_verification` is the holding state used when
20 * `require_guest_email_verification` is on: the row is created but is not
21 * actionable until the guest clicks the verification link, at which
22 * point {@see BookingSessionController::confirmEmailVerifiedBooking()}
23 * (the `pending_verification` → `pending` transition) takes over.
24 *
25 * Keep this list as the single source of truth — both validate* and
26 * sanitize use it, so adding a new status anywhere in the booking
27 * lifecycle just needs one edit here.
28 */
29 private const VALID_BOOKING_STATUSES = [
30 'pending',
31 'pending_verification',
32 'confirmed',
33 'cancelled',
34 'completed',
35 'refunded',
36 'waitlist',
37 ];
38
39 /**
40 * Accepted payment statuses — mirrors the `payment_status` ENUM on the
41 * bookings table.
42 */
43 private const VALID_PAYMENT_STATUSES = [
44 'pending',
45 'partial',
46 'paid',
47 'refunded',
48 'failed',
49 ];
50
51 /**
52 * Normalize a locale-formatted numeric string to a PHP-parseable form.
53 *
54 * Russian / European locales format money as "1 200,50" (space thousands +
55 * comma decimal), and some browsers/inputs submit that raw string. PHP's
56 * is_numeric() rejects it and (float) silently truncates it ("200,50" → 200),
57 * which surfaced to users as a generic "Booking validation failed" with no
58 * indication of the real cause. Normalize before validating/casting so we
59 * accept "1 200,50", "1.200,50" (EU), "1,200.50" (US) and "200,50" alike.
60 *
61 * @param mixed $value
62 * @return mixed Normalized string for numeric input, original value otherwise.
63 */
64 private static function normalizeNumeric($value)
65 {
66 if (is_int($value) || is_float($value)) {
67 return $value;
68 }
69 if (!is_string($value)) {
70 return $value;
71 }
72
73 $v = trim($value);
74 if ($v === '') {
75 return $v;
76 }
77
78 // Strip currency symbols and all whitespace used as thousands separators
79 // (regular space, NBSP U+00A0, narrow NBSP U+202F, thin space U+2009).
80 $v = preg_replace('/[\s\x{00A0}\x{202F}\x{2009}]/u', '', $v);
81
82 $lastComma = strrpos($v, ',');
83 $lastDot = strrpos($v, '.');
84
85 if ($lastComma !== false && $lastDot !== false) {
86 // Both present → the right-most one is the decimal separator.
87 if ($lastComma > $lastDot) {
88 $v = str_replace('.', '', $v); // dots are thousands
89 $v = str_replace(',', '.', $v); // comma is decimal
90 } else {
91 $v = str_replace(',', '', $v); // commas are thousands
92 }
93 } elseif ($lastComma !== false) {
94 // Only a comma → treat it as the decimal separator.
95 $v = str_replace(',', '.', $v);
96 }
97
98 return $v;
99 }
100
101 /**
102 * Validate booking creation data
103 */
104 public static function validateCreate(array $data): void
105 {
106 $errors = [];
107
108 // Required fields
109 if (empty($data['trip_id'])) {
110 $errors['trip_id'][] = __('Trip ID is required', 'yatra');
111 } elseif (!is_numeric($data['trip_id']) || (int)$data['trip_id'] <= 0) {
112 $errors['trip_id'][] = __('Trip ID must be a valid positive integer', 'yatra');
113 }
114
115 // Customer can be guest, so customer_id is optional (if provided, validate)
116 if (isset($data['customer_id']) && $data['customer_id'] !== '') {
117 if (!is_numeric($data['customer_id']) || (int)$data['customer_id'] <= 0) {
118 $errors['customer_id'][] = __('Customer ID must be a valid positive integer', 'yatra');
119 }
120 }
121
122 // Accept either departure_date or travel_date
123 $departureDate = $data['departure_date'] ?? $data['travel_date'] ?? null;
124 if (empty($departureDate)) {
125 $errors['departure_date'][] = __('Departure date is required', 'yatra');
126 } elseif (!self::isValidDate($departureDate)) {
127 $errors['departure_date'][] = __('Departure date must be a valid date', 'yatra');
128 } elseif (strtotime($departureDate) < strtotime('today')) {
129 $errors['departure_date'][] = __('Departure date cannot be in the past', 'yatra');
130 }
131
132 // Validate status
133 if (isset($data['status'])) {
134 if (!in_array($data['status'], self::VALID_BOOKING_STATUSES, true)) {
135 $errors['status'][] = __('Invalid booking status', 'yatra');
136 }
137 }
138
139 // Validate pricing (locale-tolerant: accept "1 200,50" / "1.200,50" etc.)
140 if (isset($data['total_amount'])) {
141 $totalAmount = self::normalizeNumeric($data['total_amount']);
142 if (!is_numeric($totalAmount) || (float)$totalAmount < 0) {
143 $errors['total_amount'][] = __('Total amount must be a valid positive number', 'yatra');
144 }
145 }
146
147 if (isset($data['paid_amount'])) {
148 $paidAmount = self::normalizeNumeric($data['paid_amount']);
149 if (!is_numeric($paidAmount) || (float)$paidAmount < 0) {
150 $errors['paid_amount'][] = __('Paid amount must be a valid positive number', 'yatra');
151 }
152 }
153
154 // Validate traveler count
155 $travelerCount = $data['total_travelers'] ?? $data['travelers_count'] ?? null;
156 if ($travelerCount !== null) {
157 if (!is_numeric($travelerCount) || (int)$travelerCount < 1) {
158 $errors['total_travelers'][] = __('Total travelers must be at least 1', 'yatra');
159 }
160 }
161
162 // Payment: booking "amount type" (full / deposit / partial) vs gateway (processor).
163 // Checkout sends both; Pro Flexible Payments uses payment_method=deposit|partial|full.
164 $bookingAmountMethods = ['full', 'partial', 'deposit'];
165 $gatewayIds = apply_filters('yatra_valid_booking_payment_gateway_ids', [
166 'cash',
167 'bank_transfer',
168 'credit_card',
169 'paypal',
170 'stripe',
171 'razorpay',
172 'pay_later',
173 'paystack',
174 'mollie',
175 'square',
176 'authorize_net',
177 'esewa',
178 'khalti',
179 ]);
180
181 if (isset($data['payment_method']) && $data['payment_method'] !== '') {
182 if (!in_array($data['payment_method'], $bookingAmountMethods, true)) {
183 // Legacy: some clients put the gateway id in payment_method only
184 if (!in_array($data['payment_method'], $gatewayIds, true)) {
185 $errors['payment_method'][] = __('Invalid payment method', 'yatra');
186 }
187 }
188 }
189
190 if (isset($data['payment_gateway']) && $data['payment_gateway'] !== '') {
191 if (!in_array($data['payment_gateway'], $gatewayIds, true)) {
192 $errors['payment_gateway'][] = __('Invalid payment gateway', 'yatra');
193 }
194 }
195
196 // Validate email format
197 if (isset($data['customer_email']) && !empty($data['customer_email'])) {
198 if (!is_email($data['customer_email'])) {
199 $errors['customer_email'][] = __('Invalid email format', 'yatra');
200 }
201 }
202
203 if (!empty($errors)) {
204 throw new ValidationException('Booking validation failed', $errors);
205 }
206 }
207
208 /**
209 * Validate booking update data
210 */
211 public static function validateUpdate(array $data, int $bookingId): void
212 {
213 $errors = [];
214
215 // ID validation
216 if ($bookingId <= 0) {
217 $errors['id'][] = __('Invalid booking ID', 'yatra');
218 }
219
220 // Optional field validation
221 if (isset($data['trip_id']) && (!is_numeric($data['trip_id']) || (int)$data['trip_id'] <= 0)) {
222 $errors['trip_id'][] = __('Trip ID must be a valid positive integer', 'yatra');
223 }
224
225 if (isset($data['customer_id']) && (!is_numeric($data['customer_id']) || (int)$data['customer_id'] <= 0)) {
226 $errors['customer_id'][] = __('Customer ID must be a valid positive integer', 'yatra');
227 }
228
229 if (isset($data['departure_date'])) {
230 if (!self::isValidDate($data['departure_date'])) {
231 $errors['departure_date'][] = __('Departure date must be a valid date', 'yatra');
232 }
233 }
234
235 if (isset($data['status'])) {
236 if (!in_array($data['status'], self::VALID_BOOKING_STATUSES, true)) {
237 $errors['status'][] = __('Invalid booking status', 'yatra');
238 }
239 }
240
241 // Reject rather than fall through to sanitize(), which coerces an
242 // unknown value to 'pending'. On an update that silently reset a
243 // fully-paid booking to unpaid while amount_paid kept the money that had
244 // actually been received — and still reported success.
245 if (isset($data['payment_status'])) {
246 if (!in_array($data['payment_status'], self::VALID_PAYMENT_STATUSES, true)) {
247 $errors['payment_status'][] = __('Invalid payment status', 'yatra');
248 }
249 }
250
251 if (isset($data['total_amount']) && (!is_numeric(self::normalizeNumeric($data['total_amount'])) || (float)self::normalizeNumeric($data['total_amount']) < 0)) {
252 $errors['total_amount'][] = __('Total amount must be a valid positive number', 'yatra');
253 }
254
255 if (isset($data['paid_amount']) && (!is_numeric(self::normalizeNumeric($data['paid_amount'])) || (float)self::normalizeNumeric($data['paid_amount']) < 0)) {
256 $errors['paid_amount'][] = __('Paid amount must be a valid positive number', 'yatra');
257 }
258
259 if (isset($data['total_travelers']) && (!is_numeric($data['total_travelers']) || (int)$data['total_travelers'] < 1)) {
260 $errors['total_travelers'][] = __('Total travelers must be at least 1', 'yatra');
261 }
262
263 if (isset($data['payment_method'])) {
264 $validMethods = ['cash', 'bank_transfer', 'credit_card', 'paypal', 'stripe', 'razorpay'];
265 if (!in_array($data['payment_method'], $validMethods)) {
266 $errors['payment_method'][] = __('Invalid payment method', 'yatra');
267 }
268 }
269
270 if (isset($data['customer_email']) && !empty($data['customer_email']) && !is_email($data['customer_email'])) {
271 $errors['customer_email'][] = __('Invalid email format', 'yatra');
272 }
273
274 if (!empty($errors)) {
275 throw new ValidationException('Booking validation failed', $errors);
276 }
277 }
278
279 /**
280 * Sanitize booking data
281 */
282 public static function sanitize(array $data): array
283 {
284 $sanitized = [];
285
286 // Integer fields
287 if (isset($data['trip_id'])) {
288 $sanitized['trip_id'] = (int)$data['trip_id'];
289 }
290
291 if (isset($data['customer_id'])) {
292 $sanitized['customer_id'] = (int)$data['customer_id'];
293 }
294
295 if (isset($data['total_travelers'])) {
296 $sanitized['total_travelers'] = (int)$data['total_travelers'];
297 }
298 if (isset($data['travelers_count'])) {
299 $sanitized['travelers_count'] = (int)$data['travelers_count'];
300 }
301
302 // Float fields (normalize locale formatting so "200,50" stores as 200.50,
303 // not silently truncated to 200 by a bare (float) cast).
304 if (isset($data['total_amount'])) {
305 $sanitized['total_amount'] = (float)self::normalizeNumeric($data['total_amount']);
306 }
307
308 if (isset($data['paid_amount'])) {
309 $sanitized['paid_amount'] = (float)self::normalizeNumeric($data['paid_amount']);
310 }
311
312 // Date fields
313 if (isset($data['departure_date'])) {
314 $sanitized['departure_date'] = sanitize_text_field($data['departure_date']);
315 }
316 if (isset($data['travel_date'])) {
317 $sanitized['travel_date'] = sanitize_text_field($data['travel_date']);
318 }
319
320 if (isset($data['booking_date'])) {
321 $sanitized['booking_date'] = sanitize_text_field($data['booking_date']);
322 }
323
324 // Text fields
325 if (isset($data['customer_name'])) {
326 $sanitized['customer_name'] = sanitize_text_field($data['customer_name']);
327 }
328
329 if (isset($data['customer_email'])) {
330 $sanitized['customer_email'] = sanitize_email($data['customer_email']);
331 }
332
333 if (isset($data['customer_phone'])) {
334 $sanitized['customer_phone'] = sanitize_text_field($data['customer_phone']);
335 }
336
337 if (isset($data['notes'])) {
338 $sanitized['notes'] = wp_kses_post($data['notes']);
339 }
340
341 // Enum fields
342 if (isset($data['status'])) {
343 $sanitized['status'] = in_array($data['status'], self::VALID_BOOKING_STATUSES, true) ? $data['status'] : 'pending';
344 }
345
346 if (isset($data['payment_method'])) {
347 // Align with allowed frontend values (full/partial or gateway handles)
348 $validMethods = [
349 'full',
350 'partial',
351 'cash',
352 'bank_transfer',
353 'credit_card',
354 'paypal',
355 'stripe',
356 'razorpay',
357 'pay_later',
358 'paystack',
359 'mollie',
360 'square',
361 'authorize_net',
362 'esewa',
363 'khalti',
364 ];
365 $sanitized['payment_method'] = in_array($data['payment_method'], $validMethods, true)
366 ? $data['payment_method']
367 : $data['payment_method']; // keep original so validation can report exact value
368 }
369 if (isset($data['payment_gateway'])) {
370 $sanitized['payment_gateway'] = sanitize_text_field($data['payment_gateway']);
371 }
372
373 if (isset($data['payment_status'])) {
374 $validStatuses = ['pending', 'paid', 'partial', 'refunded', 'failed'];
375 $sanitized['payment_status'] = in_array($data['payment_status'], $validStatuses) ? $data['payment_status'] : 'pending';
376 }
377
378 // Tax fields
379 if (isset($data['subtotal'])) {
380 $sanitized['subtotal'] = (float)self::normalizeNumeric($data['subtotal']);
381 }
382 if (isset($data['tax_amount'])) {
383 $sanitized['tax_amount'] = (float)self::normalizeNumeric($data['tax_amount']);
384 }
385 if (isset($data['tax_rate'])) {
386 $sanitized['tax_rate'] = (float)self::normalizeNumeric($data['tax_rate']);
387 }
388 if (isset($data['tax_inclusive'])) {
389 $sanitized['tax_inclusive'] = (bool)$data['tax_inclusive'];
390 }
391 if (isset($data['tax_details'])) {
392 $sanitized['tax_details'] = $data['tax_details']; // Already JSON encoded
393 }
394
395 // Other booking fields
396 if (isset($data['currency'])) {
397 $sanitized['currency'] = sanitize_text_field($data['currency']);
398 }
399 if (isset($data['amount_due'])) {
400 $sanitized['amount_due'] = (float)self::normalizeNumeric($data['amount_due']);
401 }
402 if (isset($data['amount_paid'])) {
403 $sanitized['amount_paid'] = (float)self::normalizeNumeric($data['amount_paid']);
404 }
405 if (isset($data['discount_amount'])) {
406 $sanitized['discount_amount'] = (float)self::normalizeNumeric($data['discount_amount']);
407 }
408 if (isset($data['discount_code'])) {
409 $sanitized['discount_code'] = sanitize_text_field($data['discount_code']);
410 }
411 if (isset($data['reference'])) {
412 $sanitized['reference'] = sanitize_text_field($data['reference']);
413 }
414 if (isset($data['contact_first_name'])) {
415 $sanitized['contact_first_name'] = sanitize_text_field($data['contact_first_name']);
416 }
417 if (isset($data['contact_last_name'])) {
418 $sanitized['contact_last_name'] = sanitize_text_field($data['contact_last_name']);
419 }
420 if (isset($data['contact_email'])) {
421 $sanitized['contact_email'] = sanitize_email($data['contact_email']);
422 }
423 if (isset($data['contact_phone'])) {
424 $sanitized['contact_phone'] = sanitize_text_field($data['contact_phone']);
425 }
426 if (isset($data['contact_country'])) {
427 $sanitized['contact_country'] = sanitize_text_field($data['contact_country']);
428 }
429 // contact_data / emergency_contact arrive either as an already-encoded
430 // JSON string (some internal callers) or — from the admin BookingForm —
431 // as a plain object/array. Sanitise the array form per-value (the
432 // checkout path already sanitises its captures), and pass an
433 // already-encoded string through untouched.
434 if (isset($data['contact_data'])) {
435 $sanitized['contact_data'] = is_array($data['contact_data'])
436 ? self::sanitizeFieldMap($data['contact_data'])
437 : $data['contact_data'];
438 }
439 if (isset($data['emergency_contact'])) {
440 $sanitized['emergency_contact'] = is_array($data['emergency_contact'])
441 ? self::sanitizeFieldMap($data['emergency_contact'])
442 : $data['emergency_contact'];
443 }
444 // Travelers: array of flat field maps (field_id => value), incl. CUSTOM
445 // fields from the Pro Dynamic Form module. Previously omitted from the
446 // allowlist, which silently dropped admin traveller edits before they
447 // reached BookingService::saveTravelers(). Keys are normalised with
448 // sanitize_key (the same shape the form builder produces); scalar values
449 // only. Checkout does NOT pass a `travelers` key (it persists travellers
450 // through a separate path), so this is additive for the admin flow only.
451 if (isset($data['travelers']) && is_array($data['travelers'])) {
452 $sanitized_travelers = [];
453 foreach ($data['travelers'] as $traveler) {
454 if (!is_array($traveler)) {
455 continue;
456 }
457 $sanitized_travelers[] = self::sanitizeFieldMap($traveler);
458 }
459 $sanitized['travelers'] = $sanitized_travelers;
460 }
461 if (isset($data['availability_id'])) {
462 $sanitized['availability_id'] = !empty($data['availability_id']) ? (int)$data['availability_id'] : null;
463 }
464 if (isset($data['user_id'])) {
465 $sanitized['user_id'] = !empty($data['user_id']) ? (int)$data['user_id'] : null;
466 }
467 if (isset($data['special_requests'])) {
468 $sanitized['special_requests'] = sanitize_textarea_field($data['special_requests']);
469 }
470 if (isset($data['newsletter_optin'])) {
471 $sanitized['newsletter_optin'] = (int)(bool)$data['newsletter_optin'];
472 }
473 if (isset($data['ip_address'])) {
474 $sanitized['ip_address'] = sanitize_text_field($data['ip_address']);
475 }
476 if (isset($data['created_at'])) {
477 $sanitized['created_at'] = sanitize_text_field($data['created_at']);
478 }
479 if (isset($data['updated_at'])) {
480 $sanitized['updated_at'] = sanitize_text_field($data['updated_at']);
481 }
482
483 // Itinerary costs fields
484 if (isset($data['itinerary_costs'])) {
485 $sanitized['itinerary_costs'] = $data['itinerary_costs']; // Already JSON encoded
486 }
487 if (isset($data['itinerary_costs_total'])) {
488 $sanitized['itinerary_costs_total'] = (float)self::normalizeNumeric($data['itinerary_costs_total']);
489 }
490 if (isset($data['departure_time'])) {
491 $t = trim((string) $data['departure_time']);
492 $sanitized['departure_time'] = $t !== '' ? sanitize_text_field($t) : '';
493 }
494
495 return $sanitized;
496 }
497
498 /**
499 * Sanitise a flat field map (field_id => value), e.g. contact_data or
500 * emergency_contact submitted as an object by the admin BookingForm.
501 * Keys are normalised with sanitize_key (matching the form-builder /
502 * merge-tag key shape) and scalar values run through sanitize_text_field.
503 * Non-scalar values are dropped.
504 *
505 * @param array<string,mixed> $map
506 * @return array<string,string>
507 */
508 private static function sanitizeFieldMap(array $map): array
509 {
510 $clean = [];
511 foreach ($map as $key => $value) {
512 if (!is_scalar($value)) {
513 continue;
514 }
515 $clean_key = sanitize_key((string) $key);
516 if ($clean_key !== '') {
517 $clean[$clean_key] = sanitize_text_field((string) $value);
518 }
519 }
520 return $clean;
521 }
522
523 /**
524 * Check if date is valid
525 */
526 private static function isValidDate(string $date): bool
527 {
528 $d = \DateTime::createFromFormat('Y-m-d', $date);
529 return $d && $d->format('Y-m-d') === $date;
530 }
531 }
532