PluginProbe
Yatra – Travel Booking & Tour Operator Software / 3.0.17
Yatra – Travel Booking & Tour Operator Software v3.0.17
3.0.17 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 All 85 releases
← All changes | app/Controllers/SettingsController.php +592 -83 3.0.2.6 → 3.0.17 View file →
@@ -44,22 +44,47 @@
44 44 'company_logo' => '',
45 45 'timezone' => 'UTC',
46 46 'date_format' => 'Y-m-d',
47 47 'time_format' => 'H:i',
48 -
48 + 'frontend_primary_color' => '#3b82f6',
49 + 'frontend_container_max_width' => '',
50 + // Trip listing card density. 'standard' = the current comfortable card;
51 + // 'compact_mobile' = compact card on phones/tablets only (desktop grid
52 + // unchanged); 'compact_all' = compact card at every screen size.
53 + 'frontend_listing_card_layout' => 'standard',
54 +
49 55 // Booking Settings
50 56 'booking_confirmation' => true,
57 + // Legacy boolean, kept for backward compatibility. Superseded by
58 + // 'auto_confirm_mode' below; the mode is authoritative once stored.
51 59 'auto_confirm_bookings' => false,
60 + // Auto-confirm mode: 'none' (never), 'online' (only successful online
61 + // gateway payments), or 'all' (confirm every booking at checkout).
62 + // Default 'online' (payment complete => confirmed). Existing sites with
63 + // no stored mode resolve on the fly via yatra_get_auto_confirm_mode()
64 + // (legacy true->all, false->online), preserving their prior behaviour.
65 + 'auto_confirm_mode' => 'online',
52 66 'auto_confirm_pay_later' => true,
53 67 'require_login' => false,
54 68 'allow_guest_checkout' => true,
55 - 'cancellation_policy' => 'full_refund',
56 - 'cancellation_days' => 7,
57 - 'refund_policy' => '',
69 + // cancellation_policy / cancellation_days / refund_policy were
70 + // removed in 3.0.5 — they only inserted text into the booking
71 + // confirmation email but did NOT enforce a cancellation cutoff
72 + // because Yatra has no customer-facing self-service
73 + // cancellation flow. Per-trip cancellation copy on the Trip
74 + // editor is the supported way to communicate policy. If those
75 + // legacy options still exist in wp_options on upgraded sites
76 + // they're harmless orphans — the save endpoint no longer
77 + // accepts them, and the email template skips the cancellation
78 + // paragraph when the global setting is absent.
58 79 'booking_expiry_hours' => 24,
59 80 'booking_reminder_days' => 3,
81 + 'availability_horizon_months' => 12,
60 82 'allow_waitlist' => true,
61 83 'waitlist_auto_confirm' => false,
84 + // Pro: render available departure dates as a <select> instead of a
85 + // flatpickr calendar on the single-trip sidebar (desktop + mobile).
86 + 'date_picker_as_dropdown' => false,
62 87
63 88 // Payment Settings
64 89 'currency' => 'USD',
65 90 'payment_test_mode' => true,
@@ -70,9 +95,21 @@
70 95 'deposit_required' => false,
71 96 'deposit_percentage' => 20,
72 97 'gateway_configs' => [],
73 98 'gateway_order' => [],
74 -
99 +
100 + // Discount Stacking Mode — controls how the Advanced Discount and
101 + // Dynamic Pricing modules combine when both can fire on the same
102 + // booking. Default 'both' preserves the legacy stacked behavior
103 + // (discount on top of DP-adjusted price). The Settings → Pricing
104 + // tab only surfaces this setting when BOTH modules are enabled,
105 + // and CalculationService only enforces a non-default mode when
106 + // BOTH modules are loaded — so sites with only one (or neither)
107 + // module see zero behavior change.
108 + //
109 + // Allowed: 'both' | 'discount_only' | 'dynamic_pricing_only' | 'best_for_customer'
110 + 'discount_stacking_mode' => 'both',
111 +
75 112 // Scheduled/Recurring Payment Settings (Pro feature - defaults disabled)
76 113 'enable_scheduled_payments' => false,
77 114 'scheduled_payment_type' => 'single', // single, installments
78 115 'scheduled_payment_days' => 15, // Days until first scheduled payment
@@ -78,8 +115,10 @@
78 115 'scheduled_payment_days' => 15, // Days until first scheduled payment
79 116 'scheduled_payment_installments' => 1, // Number of installments (if type is installments)
80 117 'scheduled_payment_interval' => 30, // Days between installments
81 118 'scheduled_payment_reminder_days' => 3, // Days before to send reminder
119 + 'balance_anchor' => 'booking', // 'booking' (BC default) | 'tour' (relative to tour date)
120 + 'balance_due_days' => 14, // When anchor=tour: balance due this many days before the tour
82 121 'allow_save_payment_methods' => false,
83 122
84 123 // Email Settings (WordPress site defaults when Yatra options are missing)
85 124 'admin_email' => $wpAdminEmail,
@@ -84,15 +123,49 @@
84 123 // Email Settings (WordPress site defaults when Yatra options are missing)
85 124 'admin_email' => $wpAdminEmail,
86 125 'from_email' => $wpAdminEmail,
87 126 'from_name' => $wpSiteName,
127 + // Blind copy of every outgoing Yatra email, for archiving/monitoring.
128 + // Empty means no copy is sent; accepts several comma-separated addresses.
129 + 'email_always_bcc' => '',
88 130 'email_template_booking' => true,
89 131 'email_template_confirmation' => true,
132 + // Separate part-payment email. Off by default so existing sites keep
133 + // sending the single payment template for every payment.
134 + 'email_template_partial_payment' => false,
90 135 'email_template_cancellation' => true,
91 136 'email_template_reminder' => true,
92 137 'email_template_admin_new_booking' => true,
93 138 'email_template_admin_payment' => true,
94 139 'email_template_admin_cancellation' => true,
140 + 'email_template_trip_consent' => true,
141 + 'email_template_customer_verification' => true,
142 + 'email_template_guest_verification' => true,
143 + 'email_template_account_email_change' => true,
144 + 'email_template_account_email_changed' => true,
145 + 'email_template_booking_completed' => true,
146 + 'email_template_booking_expired_customer' => true,
147 + 'email_template_admin_booking_expired' => true,
148 + 'email_template_scheduled_payment_reminder' => true,
149 + 'email_template_scheduled_payment_succeeded' => true,
150 + 'email_template_scheduled_payment_failed' => true,
151 + 'email_template_admin_scheduled_payment_failed' => true,
152 + 'email_template_enquiry_received' => true,
153 + 'email_template_enquiry_admin' => true,
154 + 'email_template_enquiry_response' => true,
155 + // Off by default, unlike the other templates. A review request is the
156 + // one transactional email that is not a response to something the
157 + // customer just did, and several jurisdictions treat it as advertising
158 + // rather than service mail — in Germany the BGH (VI ZR 225/17) holds it
159 + // needs prior consent, and the existing-customer exemption in
160 + // §7 Abs. 3 UWG does not cover it. Shipping it on would have every new
161 + // site mailing customers for consent it has not collected, so the
162 + // operator turns it on once they have decided how they collect it.
163 + // Sites that already have it on are untouched.
164 + 'email_template_review_request' => false,
165 + 'email_template_abandoned_booking_recovery_first' => true,
166 + 'email_template_abandoned_booking_recovery_second' => true,
167 + 'email_template_abandoned_booking_recovery_final' => true,
95 168 'smtp_enabled' => false,
96 169 'smtp_host' => 'smtp.gmail.com',
97 170 'smtp_port' => 587,
98 171 'smtp_username' => '',
@@ -102,8 +175,13 @@
102 175 // Customer Settings
103 176 'customer_registration' => true,
104 177 'customer_fields' => [],
105 178 'require_email_verification' => false,
179 + // Per-booking verification for guest checkouts. Distinct from the
180 + // account-creation `require_email_verification` flag because a guest
181 + // never registers — the verification is gated on the booking itself
182 + // (BookingSessionController checks this when admitting a guest).
183 + 'require_guest_email_verification' => false,
106 184 'customer_account_page' => '',
107 185 'allow_customer_reviews' => true,
108 186 'customer_dashboard_enabled' => true,
109 187
@@ -113,9 +191,13 @@
113 191 'auto_approve_reviews' => false,
114 192 'review_moderation' => true,
115 193 'min_rating' => 1,
116 194 'allow_anonymous_reviews' => false,
117 - 'review_reminder_days' => 7,
195 + 'review_reminder_days' => 3,
196 + // Never ask about a trip that ended longer ago than this. The
197 + // reminder is anchored to the tour's end date, so a backlog of
198 + // bookings completed late is skipped rather than mailed in bulk.
199 + 'review_reminder_max_age_days' => 14,
118 200
119 201 // Tax Settings
120 202 'enable_tax' => false,
121 203 'tax_name' => __('Tax', 'yatra'),
@@ -146,9 +228,22 @@
146 228 'facebook_pixel' => '',
147 229 'recaptcha_enabled' => false,
148 230 'recaptcha_site_key' => '',
149 231 'recaptcha_secret_key' => '',
150 -
232 + // reCAPTCHA v3: score threshold (0.0-1.0) + per-form protection toggles.
233 + // All off by default so enabling reCAPTCHA alone changes nothing until
234 + // the operator picks which forms to protect.
235 + 'recaptcha_score_threshold' => 0.5,
236 + 'recaptcha_protect_enquiry' => false,
237 + 'recaptcha_protect_booking' => false,
238 + 'recaptcha_protect_registration' => false,
239 +
240 + // Uninstall. Off by default and deliberately never defaulted to true:
241 + // turning this on means deleting the operator's bookings, customers and
242 + // payment history when the plugin is removed, so it has to be a decision
243 + // somebody makes on purpose.
244 + 'delete_data_on_uninstall' => false,
245 +
151 246 // Permalink Settings
152 247 'trip_base' => 'trip',
153 248 'destination_base' => 'destination',
154 249 'activity_base' => 'activity',
@@ -155,12 +250,36 @@
155 250 'trip_category_base' => 'trip-category',
156 251 'booking_base' => 'book',
157 252 // Wishlist (Pro) — stored in free options; active only when Pro + setting on
158 253 'enable_wishlist' => false,
159 -
254 + // Sold-out date visibility on the storefront. Default true keeps the
255 + // existing behaviour (sold-out dates stay visible, badged "sold out" and
256 + // able to drive the waitlist); owners can switch it off to hide them the
257 + // same way blocked dates are hidden.
258 + 'show_sold_out' => true,
259 +
260 + // Search & Listing storefront UX. Defaults preserve current behaviour:
261 + // every search field shown (true) and mobile filters expanded (false),
262 + // so existing installs are unchanged until the owner opts in. Booleans
263 + // are auto-sanitized from the default type.
264 + 'search_show_keyword' => true,
265 + 'search_show_destination' => true,
266 + 'search_show_activities' => true,
267 + 'search_show_duration' => true,
268 + 'search_show_budget' => true,
269 + // Date field is opt-in (default false) so updating the plugin never
270 + // changes an existing site's search bar. Operators enable it to let
271 + // customers find trips with a departure on a specific date.
272 + 'search_show_date' => false,
273 + 'collapse_filters_on_mobile' => false,
274 +
160 275 // Booking Page Settings
161 276 'use_booking_page' => false,
162 277 'booking_page_id' => 0,
278 +
279 + // Legal Pages (Booking UI)
280 + 'terms_page_id' => 0,
281 + 'privacy_policy_page_id' => 0,
163 282
164 283 // SEO Settings
165 284 'seo_trip_meta_title' => '',
166 285 'seo_trip_meta_description' => '',
@@ -165,9 +284,17 @@
165 284 'seo_trip_meta_title' => '',
166 285 'seo_trip_meta_description' => '',
167 286 'seo_trip_meta_keywords' => '',
168 287 'seo_trip_meta_image' => 0,
169 -
288 + 'enable_sitemap' => true,
289 + // Which Yatra content types appear in /yatra-sitemap.xml. Defaults to
290 + // every type, so a site that never touches this keeps today's sitemap.
291 + 'sitemap_types' => ['archive', 'trip', 'destination', 'activity', 'category'],
292 + // Opt-in, and deliberately separate from the list above: dropping a type
293 + // from the sitemap is housekeeping, while noindex de-indexes pages that
294 + // may currently rank. That should never happen as a side effect.
295 + 'sitemap_noindex_excluded' => false,
296 +
170 297 // Advanced Settings
171 298 'debug_mode' => false,
172 299 'enable_logging' => false,
173 300 'cache_enabled' => true,
@@ -214,8 +341,26 @@
214 341 'permission_callback' => [$this, 'check_permission'],
215 342 ],
216 343 ]);
217 344
345 + // Booking form config, optionally resolved for one trip (Pro form
346 + // conditions). Readable by anyone who can view bookings, so the
347 + // booking detail screen can label the fields a trip actually asked.
348 + register_rest_route($namespace, '/' . $base . '/booking-form', [
349 + [
350 + 'methods' => \WP_REST_Server::READABLE,
351 + 'callback' => [$this, 'get_booking_form_config'],
352 + 'permission_callback' => [$this, 'check_booking_form_permission'],
353 + 'args' => [
354 + 'trip_id' => [
355 + 'type' => 'integer',
356 + 'required' => false,
357 + 'sanitize_callback' => 'absint',
358 + ],
359 + ],
360 + ],
361 + ]);
362 +
218 363 // Get WordPress pages for booking page selection
219 364 register_rest_route($namespace, '/' . $base . '/pages', [
220 365 [
221 366 'methods' => \WP_REST_Server::READABLE,
@@ -283,20 +428,60 @@
283 428 return $this->error_response($e->getMessage(), 500);
284 429 }
285 430 }
286 431
432 + /**
433 + * Plugin settings — high-sensitivity cap. By default only the
434 + * Owner role holds `yatra_manage_settings` (Manager doesn't, by
435 + * design — settings include payment gateway routing, email
436 + * delivery configuration, currency formatting and similar
437 + * global behaviour). WP admins pass via the Team module's
438 + * admin-fallback filter.
439 + */
287 440 public function check_permission(?WP_REST_Request $request = null): bool
288 441 {
289 442 if (!is_user_logged_in()) {
290 443 return false;
291 444 }
445 + return current_user_can('yatra_manage_settings');
446 + }
292 447
293 - // Match other Yatra admin surfaces (e.g. Email Automation, Pro modules)
294 - return current_user_can('manage_options')
295 - || current_user_can('manage_yatra');
448 + /**
449 + * The booking form config is needed to label booking data, so it is
450 + * readable by booking staff, not only settings managers.
451 + */
452 + public function check_booking_form_permission(?WP_REST_Request $request = null): bool
453 + {
454 + if (!is_user_logged_in()) {
455 + return false;
456 + }
457 + return current_user_can('yatra_manage_settings')
458 + || current_user_can('yatra_view_bookings')
459 + || current_user_can('yatra_edit_bookings');
296 460 }
297 461
298 462 /**
463 + * GET /settings/booking-form[?trip_id=N]
464 + *
465 + * Without trip_id: the full config exactly as the Settings screen sees it.
466 + * With trip_id: the config as that trip's checkout renders it — Pro form
467 + * conditions resolved (no Pro / no conditions → identical to the global).
468 + */
469 + public function get_booking_form_config(WP_REST_Request $request)
470 + {
471 + try {
472 + $trip_id = (int) $request->get_param('trip_id');
473 +
474 + return $this->success_response([
475 + 'booking_form_config' => \Yatra\Services\SettingsService::getBookingFormConfig($trip_id > 0 ? $trip_id : null),
476 + 'trip_id' => $trip_id > 0 ? $trip_id : null,
477 + ]);
478 + } catch (\Exception $e) {
479 + return $this->error_response($e->getMessage(), 500);
480 + }
481 + }
482 +
483 + /**
299 484 * Get all settings
300 485 */
301 486 public function get_settings(WP_REST_Request $request)
302 487 {
@@ -302,18 +487,36 @@
302 487 {
303 488 try {
304 489 $settings = [];
305 490
306 - // Get all settings from WordPress options table with yatra_ prefix
491 + // Get all settings from WordPress options table with yatra_ prefix.
492 + // A sentinel default is essential here: get_option() returns boolean
493 + // false for a stored-false option just as it does for a missing one,
494 + // so checking `=== false` would reset every saved-off boolean back to
495 + // its default. That is exactly the "Show sold-out dates" bug — the
496 + // storefront honoured the saved value (isEnabled coerces '' -> false)
497 + // while the admin checkbox re-appeared enabled because this endpoint
498 + // handed React the default (true) instead of the saved false.
499 + $unset_sentinel = "\0__yatra_option_unset__\0";
307 500 foreach ($this->default_settings as $key => $default_value) {
308 501 $option_name = 'yatra_' . $key;
309 - $value = get_option($option_name, false);
310 -
311 - // Only use default if option doesn't exist (wasn't set by InstallerService)
312 - if ($value === false) {
502 + $value = get_option($option_name, $unset_sentinel);
503 +
504 + // Only use default when the option truly does not exist.
505 + if ($value === $unset_sentinel) {
313 506 $value = $default_value;
314 507 }
315 508
509 + // Auto-Confirm mode has no stored default — it is resolved on
510 + // the fly. Return the effective mode so the admin shows the
511 + // site's real behaviour: a stored choice if the operator made
512 + // one, otherwise derived from the legacy boolean
513 + // (true -> 'all', false -> 'online'). Prevents an existing
514 + // "confirm all" site from displaying (and re-saving) as 'online'.
515 + if ($key === 'auto_confirm_mode' && function_exists('yatra_get_auto_confirm_mode')) {
516 + $value = yatra_get_auto_confirm_mode();
517 + }
518 +
316 519 // Stored empty string should behave like "unset" for delivery identity (matches installer / backfill).
317 520 if (($key === 'admin_email' || $key === 'from_email') && is_string($value) && trim($value) === '') {
318 521 $wp = (string) get_option('admin_email', '');
319 522 $value = $wp !== '' ? $wp : $value;
@@ -331,9 +534,18 @@
331 534 // Ensure arrays are returned as arrays (not objects)
332 535 if (is_array($default_value) && !is_array($value)) {
333 536 $value = [];
334 537 }
335 -
538 +
539 + // Boolean settings must round-trip to the admin as real booleans.
540 + // update_option() stores false as '' and the object cache can
541 + // return boolean false, so without this a disabled toggle would
542 + // reach React as '' / false and the checkbox (checked unless the
543 + // value is strictly !== false) would render enabled again.
544 + if (is_bool($default_value)) {
545 + $value = filter_var($value, FILTER_VALIDATE_BOOLEAN);
546 + }
547 +
336 548 $settings[$key] = $value;
337 549 }
338 550
339 551 // Special handling for booking_form_config - always use getBookingFormConfig which handles locked fields
@@ -344,10 +556,42 @@
344 556 if (!empty($flexible_payment_settings)) {
345 557 $settings = array_merge($settings, $flexible_payment_settings);
346 558 }
347 559
560 + $scheduled_payment_settings = apply_filters('yatra_get_scheduled_payment_settings', []);
561 + if (!empty($scheduled_payment_settings)) {
562 + $settings = array_merge($settings, $scheduled_payment_settings);
563 + }
564 +
565 + // Scheduled payment keys are owned by Pro (yatra_pro_scheduled_payments), not yatra_* options.
566 + foreach (
567 + [
568 + 'enable_scheduled_payments',
569 + 'scheduled_payment_type',
570 + 'scheduled_payment_days',
571 + 'scheduled_payment_installments',
572 + 'scheduled_payment_interval',
573 + 'scheduled_payment_reminder_days',
574 + 'balance_anchor',
575 + 'balance_due_days',
576 + ] as $sk
577 + ) {
578 + if (array_key_exists($sk, $this->default_settings)) {
579 + $settings[$sk] = \Yatra\Services\SettingsService::get(
580 + $sk,
581 + $this->default_settings[$sk]
582 + );
583 + }
584 + }
585 +
348 586 $settings = $this->syncAccountRouteSettingsForResponse($settings);
349 587
588 + /**
589 + * Allow Pro modules to align REST payloads with canonical option stores
590 + * (e.g. GA4 settings that also live in yatra_google_analytics_settings).
591 + */
592 + $settings = apply_filters('yatra_rest_settings', $settings);
593 +
350 594 return $this->success_response($settings);
351 595 } catch (\Exception $e) {
352 596 return $this->error_response($e->getMessage(), 500);
353 597 }
@@ -372,19 +616,32 @@
372 616 $is_dynamic_form_enabled = apply_filters('yatra_dynamic_form_field_enabled', false);
373 617
374 618 // Check if Flexible Payments module is enabled (Pro feature)
375 619 $is_flexible_payments_enabled = apply_filters('yatra_flexible_payments_enabled', false);
620 +
621 + $is_scheduled_payments_module = apply_filters('yatra_scheduled_payments_module_active', false);
376 622
377 623 // Flexible payment settings keys (Pro only)
378 624 $flexible_payment_keys = [
379 625 'deposit_required', 'deposit_percentage', 'partial_payment',
380 - 'partial_payment_percentage', 'enable_deposit', 'enable_scheduled_payments',
381 - 'scheduled_payment_type', 'scheduled_payment_days', 'scheduled_payment_installments',
382 - 'scheduled_payment_interval', 'scheduled_payment_reminder_days', 'allow_save_payment_methods',
626 + 'partial_payment_percentage', 'enable_deposit', 'allow_save_payment_methods',
383 627 ];
628 +
629 + $scheduled_payment_keys = [
630 + 'enable_scheduled_payments',
631 + 'scheduled_payment_type',
632 + 'scheduled_payment_days',
633 + 'scheduled_payment_installments',
634 + 'scheduled_payment_interval',
635 + 'scheduled_payment_reminder_days',
636 + 'balance_anchor',
637 + 'balance_due_days',
638 + ];
384 639
385 640 // Collect flexible payment settings to delegate to Pro
386 641 $flexible_payment_settings = [];
642 +
643 + $scheduled_payment_settings_batch = [];
387 644
388 645 // Process each setting
389 646 foreach ($data as $key => $value) {
390 647 // Skip booking_form_config if Dynamic Form Field module is not enabled
@@ -401,8 +658,15 @@
401 658 // Skip saving in Free plugin - Pro handles these
402 659 continue;
403 660 }
404 661
662 + if (in_array($key, $scheduled_payment_keys, true)) {
663 + if ($is_scheduled_payments_module) {
664 + $scheduled_payment_settings_batch[$key] = $value;
665 + }
666 + continue;
667 + }
668 +
405 669 // Wishlist toggle: only meaningful with Yatra Pro active
406 670 if ($key === 'enable_wishlist' && !apply_filters('yatra_is_pro_active', false)) {
407 671 continue;
408 672 }
@@ -441,8 +705,13 @@
441 705 do_action('yatra_save_flexible_payment_settings', $flexible_payment_settings);
442 706 $updated = array_merge($updated, array_keys($flexible_payment_settings));
443 707 }
444 708
709 + if (!empty($scheduled_payment_settings_batch) && $is_scheduled_payments_module) {
710 + do_action('yatra_save_scheduled_payment_settings', $scheduled_payment_settings_batch);
711 + $updated = array_merge($updated, array_keys($scheduled_payment_settings_batch));
712 + }
713 +
445 714 // Sync currency keys: keep 'currency' and 'default_currency' in sync
446 715 // Admin UI has both Payment Settings (currency) and Currency Settings (default_currency)
447 716 if (in_array('default_currency', $updated, true) && !in_array('currency', $updated, true)) {
448 717 $sync_currency = get_option('yatra_default_currency', 'USD');
@@ -484,12 +753,78 @@
484 753 if (!empty($updated)) {
485 754 \Yatra\Services\SettingsService::reload();
486 755 }
487 756
488 - return $this->success_response([
757 + // Cross-validation: booking-auth settings interact via OR
758 + // logic in booking-content.php, so some combinations are
759 + // semantically inconsistent or redundant. We don't block
760 + // the save (the resulting state still has well-defined
761 + // behavior), but we surface a clear notice so the operator
762 + // understands what they just configured.
763 + //
764 + // require_login=true + allow_guest_checkout=true →
765 + // require_login wins; allow_guest_checkout is a no-op.
766 + // require_login=true + allow_guest_checkout=false →
767 + // Strictest setting (login required, no guest path).
768 + // Internally consistent.
769 + // require_login=false + allow_guest_checkout=false →
770 + // Guests blocked, logged-in users can book. Consistent.
771 + // require_login=false + allow_guest_checkout=true →
772 + // Default. Permissive.
773 + $notices = [];
774 + $effective_require_login = \array_key_exists('require_login', $data)
775 + ? (bool) $data['require_login']
776 + : (bool) \Yatra\Services\SettingsService::get('require_login', false);
777 + $effective_allow_guest = \array_key_exists('allow_guest_checkout', $data)
778 + ? (bool) $data['allow_guest_checkout']
779 + : (bool) \Yatra\Services\SettingsService::get('allow_guest_checkout', true);
780 +
781 + if ($effective_require_login && $effective_allow_guest) {
782 + $notices[] = [
783 + 'level' => 'warning',
784 + 'code' => 'booking_auth_redundant',
785 + 'message' => __(
786 + 'Heads up: "Require login" is on, so "Allow guest checkout" has no effect — every customer will need to log in to book. To accept guests, turn "Require login" off.',
787 + 'yatra'
788 + ),
789 + ];
790 + }
791 +
792 + // Scheduled Payments + guest checkout — incompatible at
793 + // the gateway level. Scheduled charges require a saved
794 + // payment-method tied to a customer record on the
795 + // gateway side (Stripe Customer, etc.), which in turn
796 + // requires a logged-in WP user. When both settings are
797 + // on, the system gracefully skips installment creation
798 + // for guest bookings — but operators expect them to
799 + // work and only discover the gap when reconciling
800 + // unpaid bookings weeks later. Surface this proactively.
801 + $effective_scheduled_payments = \array_key_exists('enable_scheduled_payments', $data)
802 + ? (bool) $data['enable_scheduled_payments']
803 + : (bool) \Yatra\Services\SettingsService::get('enable_scheduled_payments', false);
804 + if (
805 + $effective_scheduled_payments
806 + && $effective_allow_guest
807 + && !$effective_require_login
808 + ) {
809 + $notices[] = [
810 + 'level' => 'info',
811 + 'code' => 'scheduled_payments_guest_caveat',
812 + 'message' => __(
813 + 'Scheduled Payments is on with guest checkout allowed. Scheduled installments only run for bookings made by logged-in customers (they need a saved payment method tied to their account). Guest bookings will be charged in full at checkout instead. Turn on "Require login" if every booking must support installments.',
814 + 'yatra'
815 + ),
816 + ];
817 + }
818 +
819 + $response = [
489 820 'message' => 'Settings updated successfully',
490 821 'updated' => $updated,
491 - ]);
822 + ];
823 + if ($notices !== []) {
824 + $response['notices'] = $notices;
825 + }
826 + return $this->success_response($response);
492 827 } catch (\Exception $e) {
493 828 return $this->error_response($e->getMessage(), 500);
494 829 }
495 830 }
@@ -511,8 +846,19 @@
511 846 if ($filtered_value !== null) {
512 847 return $filtered_value;
513 848 }
514 849
850 + // The booking-form config has its own structured sanitiser (field type
851 + // and width whitelists, locked core fields, text-block content, per-trip
852 + // conditions). It must run BEFORE the generic
853 + // is_array($default) branch below: that branch only text-sanitises
854 + // values and was catching this key first — because its default is [] —
855 + // so the structured sanitiser further down was never reached and any
856 + // shape at all was stored.
857 + if ($key === 'booking_form_config') {
858 + return is_array($value) ? $this->sanitize_booking_form_config($value) : [];
859 + }
860 +
515 861 // Handle null values - use default
516 862 if ($value === null) {
517 863 return $default;
518 864 }
@@ -558,12 +904,15 @@
558 904 return null;
559 905 }
560 906 $int_value = (int) $value;
561 907 // Validate ranges for specific fields
562 - if ($key === 'cancellation_days' && $int_value < 0) {
908 + if ($key === 'booking_expiry_hours' && $int_value < 0) {
563 909 return null;
564 910 }
565 - if ($key === 'booking_expiry_hours' && $int_value < 0) {
911 + // Storefront booking horizon: 1–36 months. Out of range is rejected
912 + // (not clamped) so a bad write can never blank the calendar — the
913 + // previously stored value, or the 12-month default, stays in force.
914 + if ($key === 'availability_horizon_months' && ($int_value < 1 || $int_value > 36)) {
566 915 return null;
567 916 }
568 917 if ($key === 'partial_payment_percentage' && ($int_value < 0 || $int_value > 100)) {
569 918 return null;
@@ -593,8 +942,36 @@
593 942 }
594 943
595 944 // Handle strings
596 945 if (is_string($default)) {
946 + if ($key === 'timezone') {
947 + $tz = is_string($value) ? trim($value) : '';
948 + if ($tz === '') {
949 + return is_string($default) ? $default : 'UTC';
950 + }
951 + try {
952 + new \DateTimeZone($tz);
953 +
954 + return $tz;
955 + } catch (\Exception $e) {
956 + return is_string($default) ? $default : 'UTC';
957 + }
958 + }
959 + if ($key === 'currency_position') {
960 + $allowed = ['left', 'right', 'left_space', 'right_space', 'before', 'after'];
961 + $v = is_string($value) ? strtolower(trim($value)) : '';
962 +
963 + return in_array($v, $allowed, true) ? $v : (is_string($default) ? $default : 'left');
964 + }
965 + if ($key === 'discount_stacking_mode') {
966 + // Strict enum — any other value silently falls back to the
967 + // backward-compatible default so a malformed POST cannot
968 + // change pricing behavior unexpectedly.
969 + $allowed = ['both', 'discount_only', 'dynamic_pricing_only', 'best_for_customer'];
970 + $v = is_string($value) ? strtolower(trim($value)) : '';
971 +
972 + return in_array($v, $allowed, true) ? $v : 'both';
973 + }
597 974 // Special handling for specific fields
598 975 if ($key === 'company_email' || $key === 'admin_email' || $key === 'from_email' || $key === 'smtp_username') {
599 976 return sanitize_email($value);
600 977 }
@@ -600,11 +977,8 @@
600 977 }
601 978 if ($key === 'company_website' || $key === 'company_logo' || $key === 'google_analytics' || $key === 'facebook_pixel') {
602 979 return esc_url_raw($value);
603 980 }
604 - if ($key === 'refund_policy' || $key === 'cancellation_policy') {
605 - return sanitize_textarea_field($value);
606 - }
607 981 if ($key === 'seo_trip_meta_title') {
608 982 // Allow more characters for meta title, but strip HTML
609 983 return wp_strip_all_tags($value);
610 984 }
@@ -615,8 +989,26 @@
615 989 if ($key === 'seo_trip_meta_keywords') {
616 990 // Allow keywords, strip HTML and sanitize
617 991 return sanitize_text_field($value);
618 992 }
993 + if ($key === 'frontend_primary_color') {
994 + return \Yatra\Utils\FrontendThemeCss::sanitizePrimaryColor(is_string($value) ? $value : '');
995 + }
996 + if ($key === 'frontend_container_max_width') {
997 + return \Yatra\Utils\FrontendThemeCss::sanitizeContainerMaxWidthSetting(
998 + is_string($value) ? $value : ''
999 + );
1000 + }
1001 + if ($key === 'frontend_listing_card_layout') {
1002 + $allowed = ['standard', 'compact_mobile', 'compact_all'];
1003 + $v = is_string($value) ? strtolower(trim($value)) : '';
1004 + return in_array($v, $allowed, true) ? $v : 'standard';
1005 + }
1006 + if ($key === 'auto_confirm_mode') {
1007 + $allowed = ['none', 'online', 'all'];
1008 + $v = is_string($value) ? strtolower(trim($value)) : '';
1009 + return in_array($v, $allowed, true) ? $v : 'online';
1010 + }
619 1011 if (is_string($key) && strpos($key, 'email_tpl_') === 0 && substr($key, -5) === '_body') {
620 1012 return wp_kses_post((string) $value);
621 1013 }
622 1014 if (is_string($key) && strpos($key, 'email_tpl_') === 0 && substr($key, -8) === '_subject') {
@@ -632,15 +1024,8 @@
632 1024 return $this->sanitize_gateway_configs($value);
633 1025 }
634 1026 return [];
635 1027 }
636 - if ($key === 'booking_form_config') {
637 - // Handle nested array structure for booking form config
638 - if (is_array($value)) {
639 - return $this->sanitize_booking_form_config($value);
640 - }
641 - return [];
642 - }
643 1028 if ($key === 'tax_rates') {
644 1029 // Handle nested array structure for tax rates
645 1030 if (is_array($value)) {
646 1031 return $this->sanitize_tax_rates($value);
@@ -866,70 +1251,194 @@
866 1251 private function sanitize_booking_form_config(array $config): array
867 1252 {
868 1253 $sanitized = [];
869 1254 $allowed_form_types = ['contact_form', 'emergency_contact_form', 'traveler_form'];
870 - $allowed_field_types = ['text', 'email', 'tel', 'date', 'select', 'country', 'textarea', 'checkbox', 'number'];
871 - $allowed_widths = ['full', 'half', 'third'];
872 -
1255 +
873 1256 foreach ($config as $form_type => $form_config) {
874 1257 if (!in_array($form_type, $allowed_form_types, true)) {
875 1258 continue;
876 1259 }
877 -
1260 +
878 1261 $sanitized[$form_type] = [
879 1262 'title' => isset($form_config['title']) ? sanitize_text_field($form_config['title']) : '',
880 1263 'description' => isset($form_config['description']) ? sanitize_text_field($form_config['description']) : '',
881 1264 'enabled' => isset($form_config['enabled']) ? (bool) $form_config['enabled'] : true,
882 - 'fields' => [],
1265 + 'fields' => $this->sanitize_booking_form_fields($form_config['fields'] ?? null, $form_type),
883 1266 ];
884 -
885 - if (!empty($form_config['fields']) && is_array($form_config['fields'])) {
886 - foreach ($form_config['fields'] as $field) {
887 - if (!is_array($field) || empty($field['id'])) {
888 - continue;
1267 +
1268 + // Per-trip form conditions (Pro Dynamic Form Field): each condition
1269 + // is a complete alternative version of this section — its own
1270 + // title, description and field list — used on the trips it names.
1271 + // Only persisted when there is at least one, so configs saved
1272 + // without the feature stay byte-identical.
1273 + $conditions = $this->sanitize_booking_form_conditions($form_config['conditions'] ?? null, $form_type);
1274 + if ($conditions !== []) {
1275 + $sanitized[$form_type]['conditions'] = $conditions;
1276 + }
1277 + }
1278 +
1279 + return apply_filters('yatra_save_booking_form_config', $sanitized, $config);
1280 + }
1281 +
1282 + /**
1283 + * Sanitise one section's field list (global fields or a condition's fields).
1284 + *
1285 + * @param mixed $fields
1286 + * @return array<int, array<string, mixed>>
1287 + */
1288 + private function sanitize_booking_form_fields($fields, string $form_type): array
1289 + {
1290 + $allowed_field_types = ['text', 'email', 'tel', 'date', 'select', 'country', 'textarea', 'checkbox', 'number', 'text_block'];
1291 + $allowed_widths = ['full', 'half', 'third'];
1292 + $sanitized = [];
1293 +
1294 + if (empty($fields) || !is_array($fields)) {
1295 + return $sanitized;
1296 + }
1297 +
1298 + foreach ($fields as $field) {
1299 + if (!is_array($field) || empty($field['id'])) {
1300 + continue;
1301 + }
1302 +
1303 + $sanitized_field = [
1304 + 'id' => sanitize_key($field['id']),
1305 + 'type' => in_array($field['type'] ?? 'text', $allowed_field_types, true) ? $field['type'] : 'text',
1306 + 'label' => isset($field['label']) ? sanitize_text_field($field['label']) : '',
1307 + 'placeholder' => isset($field['placeholder']) ? sanitize_text_field($field['placeholder']) : '',
1308 + 'required' => isset($field['required']) ? (bool) $field['required'] : false,
1309 + 'enabled' => isset($field['enabled']) ? (bool) $field['enabled'] : true,
1310 + 'order' => isset($field['order']) ? (int) $field['order'] : 0,
1311 + 'width' => in_array($field['width'] ?? 'full', $allowed_widths, true) ? ($field['width'] ?? 'full') : 'full',
1312 + ];
1313 +
1314 + // Only persist `locked` when set: every reader treats a missing key
1315 + // as unlocked, and configs saved before this sanitiser ran never
1316 + // carried a `locked => false`, so they stay byte-identical.
1317 + if (!empty($field['locked'])) {
1318 + $sanitized_field['locked'] = true;
1319 + }
1320 +
1321 + // Handle optional section
1322 + if (!empty($field['section'])) {
1323 + $sanitized_field['section'] = sanitize_key($field['section']);
1324 + }
1325 +
1326 + // Per-traveler targeting — Traveler section only. Whitelist
1327 + // the allowed values; only persist the non-default "lead" so
1328 + // other sections and existing configs stay byte-identical.
1329 + if (
1330 + $form_type === 'traveler_form'
1331 + && ($field['applies_to'] ?? 'all') === 'lead'
1332 + ) {
1333 + $sanitized_field['applies_to'] = 'lead';
1334 + }
1335 +
1336 + // Handle options for select fields
1337 + if ($sanitized_field['type'] === 'select' && !empty($field['options']) && is_array($field['options'])) {
1338 + $sanitized_field['options'] = [];
1339 + foreach ($field['options'] as $option) {
1340 + if (is_array($option) && isset($option['value'])) {
1341 + $sanitized_field['options'][] = [
1342 + 'value' => sanitize_key($option['value']),
1343 + 'label' => isset($option['label']) ? sanitize_text_field($option['label']) : $option['value'],
1344 + ];
889 1345 }
890 -
891 - $sanitized_field = [
892 - 'id' => sanitize_key($field['id']),
893 - 'type' => in_array($field['type'] ?? 'text', $allowed_field_types, true) ? $field['type'] : 'text',
894 - 'label' => isset($field['label']) ? sanitize_text_field($field['label']) : '',
895 - 'placeholder' => isset($field['placeholder']) ? sanitize_text_field($field['placeholder']) : '',
896 - 'required' => isset($field['required']) ? (bool) $field['required'] : false,
897 - 'enabled' => isset($field['enabled']) ? (bool) $field['enabled'] : true,
898 - 'order' => isset($field['order']) ? (int) $field['order'] : 0,
899 - 'width' => in_array($field['width'] ?? 'full', $allowed_widths, true) ? $field['width'] : 'full',
900 - 'locked' => isset($field['locked']) ? (bool) $field['locked'] : false,
901 - ];
902 -
903 - // Handle optional section
904 - if (!empty($field['section'])) {
905 - $sanitized_field['section'] = sanitize_key($field['section']);
1346 + }
1347 + }
1348 +
1349 + // A text block is display-only content placed between fields:
1350 + // keep its (safe-HTML) content, and it can never be required.
1351 + if ($sanitized_field['type'] === 'text_block') {
1352 + $sanitized_field['content'] = isset($field['content']) ? wp_kses_post($field['content']) : '';
1353 + $sanitized_field['required'] = false;
1354 + }
1355 +
1356 + // Phone fields: the country-code selector is ON by default.
1357 + // Only persist the non-default `false`, so existing configs
1358 + // (which never carried this key) stay byte-identical and read
1359 + // back as ON.
1360 + if (
1361 + $sanitized_field['type'] === 'tel'
1362 + && array_key_exists('show_country_code', $field)
1363 + && !$field['show_country_code']
1364 + ) {
1365 + $sanitized_field['show_country_code'] = false;
1366 + }
1367 +
1368 + $sanitized[] = $sanitized_field;
1369 + }
1370 +
1371 + // Sort fields by order
1372 + usort($sanitized, function ($a, $b) {
1373 + return ($a['order'] ?? 0) - ($b['order'] ?? 0);
1374 + });
1375 +
1376 + return $sanitized;
1377 + }
1378 +
1379 + /**
1380 + * Sanitise a section's per-trip conditions. A condition without any
1381 + * target (trip, category or trip type) can never match and is dropped.
1382 + *
1383 + * @param mixed $conditions
1384 + * @return array<int, array<string, mixed>>
1385 + */
1386 + private function sanitize_booking_form_conditions($conditions, string $form_type): array
1387 + {
1388 + if (empty($conditions) || !is_array($conditions)) {
1389 + return [];
1390 + }
1391 +
1392 + $allowed_trip_types = ['single_day', 'multi_day', 'flexible'];
1393 + $sanitized = [];
1394 + $n = 0;
1395 +
1396 + foreach ($conditions as $condition) {
1397 + if (!is_array($condition)) {
1398 + continue;
1399 + }
1400 + $n++;
1401 +
1402 + $raw_targets = is_array($condition['targets'] ?? null) ? $condition['targets'] : [];
1403 + $targets = [];
1404 + foreach (['trips', 'categories'] as $selector) {
1405 + $ids = array_values(array_unique(array_filter(
1406 + array_map('intval', is_array($raw_targets[$selector] ?? null) ? $raw_targets[$selector] : []),
1407 + static function ($id) {
1408 + return $id > 0;
906 1409 }
907 -
908 - // Handle options for select fields
909 - if ($sanitized_field['type'] === 'select' && !empty($field['options']) && is_array($field['options'])) {
910 - $sanitized_field['options'] = [];
911 - foreach ($field['options'] as $option) {
912 - if (is_array($option) && isset($option['value'])) {
913 - $sanitized_field['options'][] = [
914 - 'value' => sanitize_key($option['value']),
915 - 'label' => isset($option['label']) ? sanitize_text_field($option['label']) : $option['value'],
916 - ];
917 - }
918 - }
919 - }
920 -
921 - $sanitized[$form_type]['fields'][] = $sanitized_field;
1410 + )));
1411 + if ($ids !== []) {
1412 + $targets[$selector] = $ids;
922 1413 }
923 -
924 - // Sort fields by order
925 - usort($sanitized[$form_type]['fields'], function($a, $b) {
926 - return ($a['order'] ?? 0) - ($b['order'] ?? 0);
927 - });
928 1414 }
1415 + $types = array_values(array_unique(array_filter(
1416 + array_map(static function ($t) {
1417 + return sanitize_key((string) $t);
1418 + }, is_array($raw_targets['trip_types'] ?? null) ? $raw_targets['trip_types'] : []),
1419 + static function ($t) use ($allowed_trip_types) {
1420 + return in_array($t, $allowed_trip_types, true);
1421 + }
1422 + )));
1423 + if ($types !== []) {
1424 + $targets['trip_types'] = $types;
1425 + }
1426 + if ($targets === []) {
1427 + continue;
1428 + }
1429 +
1430 + $id = sanitize_key((string) ($condition['id'] ?? ''));
1431 + $sanitized[] = [
1432 + 'id' => $id !== '' ? $id : 'condition_' . $n,
1433 + 'targets' => $targets,
1434 + 'title' => isset($condition['title']) ? sanitize_text_field($condition['title']) : '',
1435 + 'description' => isset($condition['description']) ? sanitize_text_field($condition['description']) : '',
1436 + 'fields' => $this->sanitize_booking_form_fields($condition['fields'] ?? null, $form_type),
1437 + ];
929 1438 }
930 -
931 - return apply_filters('yatra_save_booking_form_config', $sanitized, $config);
1439 +
1440 + return $sanitized;
932 1441 }
933 1442
934 1443 /**
935 1444 * Flush rewrite rules