PluginProbe
Yatra – Travel Booking & Tour Operator Software / 3.0.16
Yatra – Travel Booking & Tour Operator Software v3.0.16
3.0.16 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 All 84 releases
← All changes | app/Validators/BookingValidator.php +170 -24 3.0.2.8 → 3.0.16 View file →
@@ -7,14 +7,99 @@
7 7 use Yatra\Exceptions\ValidationException;
8 8
9 9 /**
10 10 * Booking Validator
11 - *
11 + *
12 12 * Comprehensive validation for booking data
13 13 */
14 14 class BookingValidator
15 15 {
16 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 + /**
17 102 * Validate booking creation data
18 103 */
19 104 public static function validateCreate(array $data): void
20 105 {
@@ -45,23 +130,24 @@
45 130 }
46 131
47 132 // Validate status
48 133 if (isset($data['status'])) {
49 - $validStatuses = ['pending', 'confirmed', 'cancelled', 'completed', 'refunded', 'waitlist'];
50 - if (!in_array($data['status'], $validStatuses, true)) {
134 + if (!in_array($data['status'], self::VALID_BOOKING_STATUSES, true)) {
51 135 $errors['status'][] = __('Invalid booking status', 'yatra');
52 136 }
53 137 }
54 138
55 - // Validate pricing
139 + // Validate pricing (locale-tolerant: accept "1 200,50" / "1.200,50" etc.)
56 140 if (isset($data['total_amount'])) {
57 - if (!is_numeric($data['total_amount']) || (float)$data['total_amount'] < 0) {
141 + $totalAmount = self::normalizeNumeric($data['total_amount']);
142 + if (!is_numeric($totalAmount) || (float)$totalAmount < 0) {
58 143 $errors['total_amount'][] = __('Total amount must be a valid positive number', 'yatra');
59 144 }
60 145 }
61 146
62 147 if (isset($data['paid_amount'])) {
63 - if (!is_numeric($data['paid_amount']) || (float)$data['paid_amount'] < 0) {
148 + $paidAmount = self::normalizeNumeric($data['paid_amount']);
149 + if (!is_numeric($paidAmount) || (float)$paidAmount < 0) {
64 150 $errors['paid_amount'][] = __('Paid amount must be a valid positive number', 'yatra');
65 151 }
66 152 }
67 153
@@ -146,19 +232,28 @@
146 232 }
147 233 }
148 234
149 235 if (isset($data['status'])) {
150 - $validStatuses = ['pending', 'confirmed', 'cancelled', 'completed', 'refunded', 'waitlist'];
151 - if (!in_array($data['status'], $validStatuses, true)) {
236 + if (!in_array($data['status'], self::VALID_BOOKING_STATUSES, true)) {
152 237 $errors['status'][] = __('Invalid booking status', 'yatra');
153 238 }
154 239 }
155 240
156 - if (isset($data['total_amount']) && (!is_numeric($data['total_amount']) || (float)$data['total_amount'] < 0)) {
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)) {
157 252 $errors['total_amount'][] = __('Total amount must be a valid positive number', 'yatra');
158 253 }
159 254
160 - if (isset($data['paid_amount']) && (!is_numeric($data['paid_amount']) || (float)$data['paid_amount'] < 0)) {
255 + if (isset($data['paid_amount']) && (!is_numeric(self::normalizeNumeric($data['paid_amount'])) || (float)self::normalizeNumeric($data['paid_amount']) < 0)) {
161 256 $errors['paid_amount'][] = __('Paid amount must be a valid positive number', 'yatra');
162 257 }
163 258
164 259 if (isset($data['total_travelers']) && (!is_numeric($data['total_travelers']) || (int)$data['total_travelers'] < 1)) {
@@ -203,15 +298,16 @@
203 298 if (isset($data['travelers_count'])) {
204 299 $sanitized['travelers_count'] = (int)$data['travelers_count'];
205 300 }
206 301
207 - // Float fields
302 + // Float fields (normalize locale formatting so "200,50" stores as 200.50,
303 + // not silently truncated to 200 by a bare (float) cast).
208 304 if (isset($data['total_amount'])) {
209 - $sanitized['total_amount'] = (float)$data['total_amount'];
305 + $sanitized['total_amount'] = (float)self::normalizeNumeric($data['total_amount']);
210 306 }
211 307
212 308 if (isset($data['paid_amount'])) {
213 - $sanitized['paid_amount'] = (float)$data['paid_amount'];
309 + $sanitized['paid_amount'] = (float)self::normalizeNumeric($data['paid_amount']);
214 310 }
215 311
216 312 // Date fields
217 313 if (isset($data['departure_date'])) {
@@ -243,10 +339,9 @@
243 339 }
244 340
245 341 // Enum fields
246 342 if (isset($data['status'])) {
247 - $validStatuses = ['pending', 'confirmed', 'cancelled', 'completed', 'refunded', 'waitlist'];
248 - $sanitized['status'] = in_array($data['status'], $validStatuses, true) ? $data['status'] : 'pending';
343 + $sanitized['status'] = in_array($data['status'], self::VALID_BOOKING_STATUSES, true) ? $data['status'] : 'pending';
249 344 }
250 345
251 346 if (isset($data['payment_method'])) {
252 347 // Align with allowed frontend values (full/partial or gateway handles)
@@ -281,15 +376,15 @@
281 376 }
282 377
283 378 // Tax fields
284 379 if (isset($data['subtotal'])) {
285 - $sanitized['subtotal'] = (float)$data['subtotal'];
380 + $sanitized['subtotal'] = (float)self::normalizeNumeric($data['subtotal']);
286 381 }
287 382 if (isset($data['tax_amount'])) {
288 - $sanitized['tax_amount'] = (float)$data['tax_amount'];
383 + $sanitized['tax_amount'] = (float)self::normalizeNumeric($data['tax_amount']);
289 384 }
290 385 if (isset($data['tax_rate'])) {
291 - $sanitized['tax_rate'] = (float)$data['tax_rate'];
386 + $sanitized['tax_rate'] = (float)self::normalizeNumeric($data['tax_rate']);
292 387 }
293 388 if (isset($data['tax_inclusive'])) {
294 389 $sanitized['tax_inclusive'] = (bool)$data['tax_inclusive'];
295 390 }
@@ -301,15 +396,15 @@
301 396 if (isset($data['currency'])) {
302 397 $sanitized['currency'] = sanitize_text_field($data['currency']);
303 398 }
304 399 if (isset($data['amount_due'])) {
305 - $sanitized['amount_due'] = (float)$data['amount_due'];
400 + $sanitized['amount_due'] = (float)self::normalizeNumeric($data['amount_due']);
306 401 }
307 402 if (isset($data['amount_paid'])) {
308 - $sanitized['amount_paid'] = (float)$data['amount_paid'];
403 + $sanitized['amount_paid'] = (float)self::normalizeNumeric($data['amount_paid']);
309 404 }
310 405 if (isset($data['discount_amount'])) {
311 - $sanitized['discount_amount'] = (float)$data['discount_amount'];
406 + $sanitized['discount_amount'] = (float)self::normalizeNumeric($data['discount_amount']);
312 407 }
313 408 if (isset($data['discount_code'])) {
314 409 $sanitized['discount_code'] = sanitize_text_field($data['discount_code']);
315 410 }
@@ -330,14 +425,40 @@
330 425 }
331 426 if (isset($data['contact_country'])) {
332 427 $sanitized['contact_country'] = sanitize_text_field($data['contact_country']);
333 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.
334 434 if (isset($data['contact_data'])) {
335 - $sanitized['contact_data'] = $data['contact_data']; // Already JSON encoded
435 + $sanitized['contact_data'] = is_array($data['contact_data'])
436 + ? self::sanitizeFieldMap($data['contact_data'])
437 + : $data['contact_data'];
336 438 }
337 439 if (isset($data['emergency_contact'])) {
338 - $sanitized['emergency_contact'] = $data['emergency_contact']; // Already JSON encoded
440 + $sanitized['emergency_contact'] = is_array($data['emergency_contact'])
441 + ? self::sanitizeFieldMap($data['emergency_contact'])
442 + : $data['emergency_contact'];
339 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 + }
340 461 if (isset($data['availability_id'])) {
341 462 $sanitized['availability_id'] = !empty($data['availability_id']) ? (int)$data['availability_id'] : null;
342 463 }
343 464 if (isset($data['user_id'])) {
@@ -363,9 +484,9 @@
363 484 if (isset($data['itinerary_costs'])) {
364 485 $sanitized['itinerary_costs'] = $data['itinerary_costs']; // Already JSON encoded
365 486 }
366 487 if (isset($data['itinerary_costs_total'])) {
367 - $sanitized['itinerary_costs_total'] = (float)$data['itinerary_costs_total'];
488 + $sanitized['itinerary_costs_total'] = (float)self::normalizeNumeric($data['itinerary_costs_total']);
368 489 }
369 490 if (isset($data['departure_time'])) {
370 491 $t = trim((string) $data['departure_time']);
371 492 $sanitized['departure_time'] = $t !== '' ? sanitize_text_field($t) : '';
@@ -371,8 +492,33 @@
371 492 $sanitized['departure_time'] = $t !== '' ? sanitize_text_field($t) : '';
372 493 }
373 494
374 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;
375 521 }
376 522
377 523 /**
378 524 * Check if date is valid