| @@ -36,8 +36,70 @@ | ||
| 36 | 36 | 'waitlist', |
| 37 | 37 | ]; |
| 38 | 38 | |
| 39 | 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 | + /** | |
| 40 | 102 | * Validate booking creation data |
| 41 | 103 | */ |
| 42 | 104 | public static function validateCreate(array $data): void |
| 43 | 105 | { |
| @@ -73,17 +135,19 @@ | ||
| 73 | 135 | $errors['status'][] = __('Invalid booking status', 'yatra'); |
| 74 | 136 | } |
| 75 | 137 | } |
| 76 | 138 | |
| 77 | - // Validate pricing | |
| 139 | + // Validate pricing (locale-tolerant: accept "1 200,50" / "1.200,50" etc.) | |
| 78 | 140 | if (isset($data['total_amount'])) { |
| 79 | - 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) { | |
| 80 | 143 | $errors['total_amount'][] = __('Total amount must be a valid positive number', 'yatra'); |
| 81 | 144 | } |
| 82 | 145 | } |
| 83 | 146 | |
| 84 | 147 | if (isset($data['paid_amount'])) { |
| 85 | - 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) { | |
| 86 | 150 | $errors['paid_amount'][] = __('Paid amount must be a valid positive number', 'yatra'); |
| 87 | 151 | } |
| 88 | 152 | } |
| 89 | 153 | |
| @@ -173,13 +237,23 @@ | ||
| 173 | 237 | $errors['status'][] = __('Invalid booking status', 'yatra'); |
| 174 | 238 | } |
| 175 | 239 | } |
| 176 | 240 | |
| 177 | - 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)) { | |
| 178 | 252 | $errors['total_amount'][] = __('Total amount must be a valid positive number', 'yatra'); |
| 179 | 253 | } |
| 180 | 254 | |
| 181 | - 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)) { | |
| 182 | 256 | $errors['paid_amount'][] = __('Paid amount must be a valid positive number', 'yatra'); |
| 183 | 257 | } |
| 184 | 258 | |
| 185 | 259 | if (isset($data['total_travelers']) && (!is_numeric($data['total_travelers']) || (int)$data['total_travelers'] < 1)) { |
| @@ -224,15 +298,16 @@ | ||
| 224 | 298 | if (isset($data['travelers_count'])) { |
| 225 | 299 | $sanitized['travelers_count'] = (int)$data['travelers_count']; |
| 226 | 300 | } |
| 227 | 301 | |
| 228 | - // 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). | |
| 229 | 304 | if (isset($data['total_amount'])) { |
| 230 | - $sanitized['total_amount'] = (float)$data['total_amount']; | |
| 305 | + $sanitized['total_amount'] = (float)self::normalizeNumeric($data['total_amount']); | |
| 231 | 306 | } |
| 232 | 307 | |
| 233 | 308 | if (isset($data['paid_amount'])) { |
| 234 | - $sanitized['paid_amount'] = (float)$data['paid_amount']; | |
| 309 | + $sanitized['paid_amount'] = (float)self::normalizeNumeric($data['paid_amount']); | |
| 235 | 310 | } |
| 236 | 311 | |
| 237 | 312 | // Date fields |
| 238 | 313 | if (isset($data['departure_date'])) { |
| @@ -301,15 +376,15 @@ | ||
| 301 | 376 | } |
| 302 | 377 | |
| 303 | 378 | // Tax fields |
| 304 | 379 | if (isset($data['subtotal'])) { |
| 305 | - $sanitized['subtotal'] = (float)$data['subtotal']; | |
| 380 | + $sanitized['subtotal'] = (float)self::normalizeNumeric($data['subtotal']); | |
| 306 | 381 | } |
| 307 | 382 | if (isset($data['tax_amount'])) { |
| 308 | - $sanitized['tax_amount'] = (float)$data['tax_amount']; | |
| 383 | + $sanitized['tax_amount'] = (float)self::normalizeNumeric($data['tax_amount']); | |
| 309 | 384 | } |
| 310 | 385 | if (isset($data['tax_rate'])) { |
| 311 | - $sanitized['tax_rate'] = (float)$data['tax_rate']; | |
| 386 | + $sanitized['tax_rate'] = (float)self::normalizeNumeric($data['tax_rate']); | |
| 312 | 387 | } |
| 313 | 388 | if (isset($data['tax_inclusive'])) { |
| 314 | 389 | $sanitized['tax_inclusive'] = (bool)$data['tax_inclusive']; |
| 315 | 390 | } |
| @@ -321,15 +396,15 @@ | ||
| 321 | 396 | if (isset($data['currency'])) { |
| 322 | 397 | $sanitized['currency'] = sanitize_text_field($data['currency']); |
| 323 | 398 | } |
| 324 | 399 | if (isset($data['amount_due'])) { |
| 325 | - $sanitized['amount_due'] = (float)$data['amount_due']; | |
| 400 | + $sanitized['amount_due'] = (float)self::normalizeNumeric($data['amount_due']); | |
| 326 | 401 | } |
| 327 | 402 | if (isset($data['amount_paid'])) { |
| 328 | - $sanitized['amount_paid'] = (float)$data['amount_paid']; | |
| 403 | + $sanitized['amount_paid'] = (float)self::normalizeNumeric($data['amount_paid']); | |
| 329 | 404 | } |
| 330 | 405 | if (isset($data['discount_amount'])) { |
| 331 | - $sanitized['discount_amount'] = (float)$data['discount_amount']; | |
| 406 | + $sanitized['discount_amount'] = (float)self::normalizeNumeric($data['discount_amount']); | |
| 332 | 407 | } |
| 333 | 408 | if (isset($data['discount_code'])) { |
| 334 | 409 | $sanitized['discount_code'] = sanitize_text_field($data['discount_code']); |
| 335 | 410 | } |
| @@ -350,14 +425,40 @@ | ||
| 350 | 425 | } |
| 351 | 426 | if (isset($data['contact_country'])) { |
| 352 | 427 | $sanitized['contact_country'] = sanitize_text_field($data['contact_country']); |
| 353 | 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. | |
| 354 | 434 | if (isset($data['contact_data'])) { |
| 355 | - $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']; | |
| 356 | 438 | } |
| 357 | 439 | if (isset($data['emergency_contact'])) { |
| 358 | - $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']; | |
| 359 | 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 | + } | |
| 360 | 461 | if (isset($data['availability_id'])) { |
| 361 | 462 | $sanitized['availability_id'] = !empty($data['availability_id']) ? (int)$data['availability_id'] : null; |
| 362 | 463 | } |
| 363 | 464 | if (isset($data['user_id'])) { |
| @@ -383,9 +484,9 @@ | ||
| 383 | 484 | if (isset($data['itinerary_costs'])) { |
| 384 | 485 | $sanitized['itinerary_costs'] = $data['itinerary_costs']; // Already JSON encoded |
| 385 | 486 | } |
| 386 | 487 | if (isset($data['itinerary_costs_total'])) { |
| 387 | - $sanitized['itinerary_costs_total'] = (float)$data['itinerary_costs_total']; | |
| 488 | + $sanitized['itinerary_costs_total'] = (float)self::normalizeNumeric($data['itinerary_costs_total']); | |
| 388 | 489 | } |
| 389 | 490 | if (isset($data['departure_time'])) { |
| 390 | 491 | $t = trim((string) $data['departure_time']); |
| 391 | 492 | $sanitized['departure_time'] = $t !== '' ? sanitize_text_field($t) : ''; |
| @@ -391,8 +492,33 @@ | ||
| 391 | 492 | $sanitized['departure_time'] = $t !== '' ? sanitize_text_field($t) : ''; |
| 392 | 493 | } |
| 393 | 494 | |
| 394 | 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; | |
| 395 | 521 | } |
| 396 | 522 | |
| 397 | 523 | /** |
| 398 | 524 | * Check if date is valid |