| @@ -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 |