PluginProbe
Yatra – Travel Booking & Tour Operator Software / 3.0.9
Yatra – Travel Booking & Tour Operator Software v3.0.9
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 2.0.11 All 82 releases
yatra / app / Validators / BookingValidator.php

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

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