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/Controllers/SettingsController.php +572 -82 3.0.2.6 → 3.0.16 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,40 @@
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 + 'email_template_review_request' => true,
156 + 'email_template_abandoned_booking_recovery_first' => true,
157 + 'email_template_abandoned_booking_recovery_second' => true,
158 + 'email_template_abandoned_booking_recovery_final' => true,
95 159 'smtp_enabled' => false,
96 160 'smtp_host' => 'smtp.gmail.com',
97 161 'smtp_port' => 587,
98 162 'smtp_username' => '',
@@ -102,8 +166,13 @@
102 166 // Customer Settings
103 167 'customer_registration' => true,
104 168 'customer_fields' => [],
105 169 'require_email_verification' => false,
170 + // Per-booking verification for guest checkouts. Distinct from the
171 + // account-creation `require_email_verification` flag because a guest
172 + // never registers — the verification is gated on the booking itself
173 + // (BookingSessionController checks this when admitting a guest).
174 + 'require_guest_email_verification' => false,
106 175 'customer_account_page' => '',
107 176 'allow_customer_reviews' => true,
108 177 'customer_dashboard_enabled' => true,
109 178
@@ -146,9 +215,16 @@
146 215 'facebook_pixel' => '',
147 216 'recaptcha_enabled' => false,
148 217 'recaptcha_site_key' => '',
149 218 'recaptcha_secret_key' => '',
150 -
219 + // reCAPTCHA v3: score threshold (0.0-1.0) + per-form protection toggles.
220 + // All off by default so enabling reCAPTCHA alone changes nothing until
221 + // the operator picks which forms to protect.
222 + 'recaptcha_score_threshold' => 0.5,
223 + 'recaptcha_protect_enquiry' => false,
224 + 'recaptcha_protect_booking' => false,
225 + 'recaptcha_protect_registration' => false,
226 +
151 227 // Permalink Settings
152 228 'trip_base' => 'trip',
153 229 'destination_base' => 'destination',
154 230 'activity_base' => 'activity',
@@ -155,12 +231,36 @@
155 231 'trip_category_base' => 'trip-category',
156 232 'booking_base' => 'book',
157 233 // Wishlist (Pro) — stored in free options; active only when Pro + setting on
158 234 'enable_wishlist' => false,
159 -
235 + // Sold-out date visibility on the storefront. Default true keeps the
236 + // existing behaviour (sold-out dates stay visible, badged "sold out" and
237 + // able to drive the waitlist); owners can switch it off to hide them the
238 + // same way blocked dates are hidden.
239 + 'show_sold_out' => true,
240 +
241 + // Search & Listing storefront UX. Defaults preserve current behaviour:
242 + // every search field shown (true) and mobile filters expanded (false),
243 + // so existing installs are unchanged until the owner opts in. Booleans
244 + // are auto-sanitized from the default type.
245 + 'search_show_keyword' => true,
246 + 'search_show_destination' => true,
247 + 'search_show_activities' => true,
248 + 'search_show_duration' => true,
249 + 'search_show_budget' => true,
250 + // Date field is opt-in (default false) so updating the plugin never
251 + // changes an existing site's search bar. Operators enable it to let
252 + // customers find trips with a departure on a specific date.
253 + 'search_show_date' => false,
254 + 'collapse_filters_on_mobile' => false,
255 +
160 256 // Booking Page Settings
161 257 'use_booking_page' => false,
162 258 'booking_page_id' => 0,
259 +
260 + // Legal Pages (Booking UI)
261 + 'terms_page_id' => 0,
262 + 'privacy_policy_page_id' => 0,
163 263
164 264 // SEO Settings
165 265 'seo_trip_meta_title' => '',
166 266 'seo_trip_meta_description' => '',
@@ -165,9 +265,17 @@
165 265 'seo_trip_meta_title' => '',
166 266 'seo_trip_meta_description' => '',
167 267 'seo_trip_meta_keywords' => '',
168 268 'seo_trip_meta_image' => 0,
169 -
269 + 'enable_sitemap' => true,
270 + // Which Yatra content types appear in /yatra-sitemap.xml. Defaults to
271 + // every type, so a site that never touches this keeps today's sitemap.
272 + 'sitemap_types' => ['archive', 'trip', 'destination', 'activity', 'category'],
273 + // Opt-in, and deliberately separate from the list above: dropping a type
274 + // from the sitemap is housekeeping, while noindex de-indexes pages that
275 + // may currently rank. That should never happen as a side effect.
276 + 'sitemap_noindex_excluded' => false,
277 +
170 278 // Advanced Settings
171 279 'debug_mode' => false,
172 280 'enable_logging' => false,
173 281 'cache_enabled' => true,
@@ -214,8 +322,26 @@
214 322 'permission_callback' => [$this, 'check_permission'],
215 323 ],
216 324 ]);
217 325
326 + // Booking form config, optionally resolved for one trip (Pro form
327 + // conditions). Readable by anyone who can view bookings, so the
328 + // booking detail screen can label the fields a trip actually asked.
329 + register_rest_route($namespace, '/' . $base . '/booking-form', [
330 + [
331 + 'methods' => \WP_REST_Server::READABLE,
332 + 'callback' => [$this, 'get_booking_form_config'],
333 + 'permission_callback' => [$this, 'check_booking_form_permission'],
334 + 'args' => [
335 + 'trip_id' => [
336 + 'type' => 'integer',
337 + 'required' => false,
338 + 'sanitize_callback' => 'absint',
339 + ],
340 + ],
341 + ],
342 + ]);
343 +
218 344 // Get WordPress pages for booking page selection
219 345 register_rest_route($namespace, '/' . $base . '/pages', [
220 346 [
221 347 'methods' => \WP_REST_Server::READABLE,
@@ -283,20 +409,60 @@
283 409 return $this->error_response($e->getMessage(), 500);
284 410 }
285 411 }
286 412
413 + /**
414 + * Plugin settings — high-sensitivity cap. By default only the
415 + * Owner role holds `yatra_manage_settings` (Manager doesn't, by
416 + * design — settings include payment gateway routing, email
417 + * delivery configuration, currency formatting and similar
418 + * global behaviour). WP admins pass via the Team module's
419 + * admin-fallback filter.
420 + */
287 421 public function check_permission(?WP_REST_Request $request = null): bool
288 422 {
289 423 if (!is_user_logged_in()) {
290 424 return false;
291 425 }
426 + return current_user_can('yatra_manage_settings');
427 + }
292 428
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');
429 + /**
430 + * The booking form config is needed to label booking data, so it is
431 + * readable by booking staff, not only settings managers.
432 + */
433 + public function check_booking_form_permission(?WP_REST_Request $request = null): bool
434 + {
435 + if (!is_user_logged_in()) {
436 + return false;
437 + }
438 + return current_user_can('yatra_manage_settings')
439 + || current_user_can('yatra_view_bookings')
440 + || current_user_can('yatra_edit_bookings');
296 441 }
297 442
298 443 /**
444 + * GET /settings/booking-form[?trip_id=N]
445 + *
446 + * Without trip_id: the full config exactly as the Settings screen sees it.
447 + * With trip_id: the config as that trip's checkout renders it — Pro form
448 + * conditions resolved (no Pro / no conditions → identical to the global).
449 + */
450 + public function get_booking_form_config(WP_REST_Request $request)
451 + {
452 + try {
453 + $trip_id = (int) $request->get_param('trip_id');
454 +
455 + return $this->success_response([
456 + 'booking_form_config' => \Yatra\Services\SettingsService::getBookingFormConfig($trip_id > 0 ? $trip_id : null),
457 + 'trip_id' => $trip_id > 0 ? $trip_id : null,
458 + ]);
459 + } catch (\Exception $e) {
460 + return $this->error_response($e->getMessage(), 500);
461 + }
462 + }
463 +
464 + /**
299 465 * Get all settings
300 466 */
301 467 public function get_settings(WP_REST_Request $request)
302 468 {
@@ -302,18 +468,36 @@
302 468 {
303 469 try {
304 470 $settings = [];
305 471
306 - // Get all settings from WordPress options table with yatra_ prefix
472 + // Get all settings from WordPress options table with yatra_ prefix.
473 + // A sentinel default is essential here: get_option() returns boolean
474 + // false for a stored-false option just as it does for a missing one,
475 + // so checking `=== false` would reset every saved-off boolean back to
476 + // its default. That is exactly the "Show sold-out dates" bug — the
477 + // storefront honoured the saved value (isEnabled coerces '' -> false)
478 + // while the admin checkbox re-appeared enabled because this endpoint
479 + // handed React the default (true) instead of the saved false.
480 + $unset_sentinel = "\0__yatra_option_unset__\0";
307 481 foreach ($this->default_settings as $key => $default_value) {
308 482 $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) {
483 + $value = get_option($option_name, $unset_sentinel);
484 +
485 + // Only use default when the option truly does not exist.
486 + if ($value === $unset_sentinel) {
313 487 $value = $default_value;
314 488 }
315 489
490 + // Auto-Confirm mode has no stored default — it is resolved on
491 + // the fly. Return the effective mode so the admin shows the
492 + // site's real behaviour: a stored choice if the operator made
493 + // one, otherwise derived from the legacy boolean
494 + // (true -> 'all', false -> 'online'). Prevents an existing
495 + // "confirm all" site from displaying (and re-saving) as 'online'.
496 + if ($key === 'auto_confirm_mode' && function_exists('yatra_get_auto_confirm_mode')) {
497 + $value = yatra_get_auto_confirm_mode();
498 + }
499 +
316 500 // Stored empty string should behave like "unset" for delivery identity (matches installer / backfill).
317 501 if (($key === 'admin_email' || $key === 'from_email') && is_string($value) && trim($value) === '') {
318 502 $wp = (string) get_option('admin_email', '');
319 503 $value = $wp !== '' ? $wp : $value;
@@ -331,9 +515,18 @@
331 515 // Ensure arrays are returned as arrays (not objects)
332 516 if (is_array($default_value) && !is_array($value)) {
333 517 $value = [];
334 518 }
335 -
519 +
520 + // Boolean settings must round-trip to the admin as real booleans.
521 + // update_option() stores false as '' and the object cache can
522 + // return boolean false, so without this a disabled toggle would
523 + // reach React as '' / false and the checkbox (checked unless the
524 + // value is strictly !== false) would render enabled again.
525 + if (is_bool($default_value)) {
526 + $value = filter_var($value, FILTER_VALIDATE_BOOLEAN);
527 + }
528 +
336 529 $settings[$key] = $value;
337 530 }
338 531
339 532 // Special handling for booking_form_config - always use getBookingFormConfig which handles locked fields
@@ -344,10 +537,42 @@
344 537 if (!empty($flexible_payment_settings)) {
345 538 $settings = array_merge($settings, $flexible_payment_settings);
346 539 }
347 540
541 + $scheduled_payment_settings = apply_filters('yatra_get_scheduled_payment_settings', []);
542 + if (!empty($scheduled_payment_settings)) {
543 + $settings = array_merge($settings, $scheduled_payment_settings);
544 + }
545 +
546 + // Scheduled payment keys are owned by Pro (yatra_pro_scheduled_payments), not yatra_* options.
547 + foreach (
548 + [
549 + 'enable_scheduled_payments',
550 + 'scheduled_payment_type',
551 + 'scheduled_payment_days',
552 + 'scheduled_payment_installments',
553 + 'scheduled_payment_interval',
554 + 'scheduled_payment_reminder_days',
555 + 'balance_anchor',
556 + 'balance_due_days',
557 + ] as $sk
558 + ) {
559 + if (array_key_exists($sk, $this->default_settings)) {
560 + $settings[$sk] = \Yatra\Services\SettingsService::get(
561 + $sk,
562 + $this->default_settings[$sk]
563 + );
564 + }
565 + }
566 +
348 567 $settings = $this->syncAccountRouteSettingsForResponse($settings);
349 568
569 + /**
570 + * Allow Pro modules to align REST payloads with canonical option stores
571 + * (e.g. GA4 settings that also live in yatra_google_analytics_settings).
572 + */
573 + $settings = apply_filters('yatra_rest_settings', $settings);
574 +
350 575 return $this->success_response($settings);
351 576 } catch (\Exception $e) {
352 577 return $this->error_response($e->getMessage(), 500);
353 578 }
@@ -372,19 +597,32 @@
372 597 $is_dynamic_form_enabled = apply_filters('yatra_dynamic_form_field_enabled', false);
373 598
374 599 // Check if Flexible Payments module is enabled (Pro feature)
375 600 $is_flexible_payments_enabled = apply_filters('yatra_flexible_payments_enabled', false);
601 +
602 + $is_scheduled_payments_module = apply_filters('yatra_scheduled_payments_module_active', false);
376 603
377 604 // Flexible payment settings keys (Pro only)
378 605 $flexible_payment_keys = [
379 606 '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',
607 + 'partial_payment_percentage', 'enable_deposit', 'allow_save_payment_methods',
383 608 ];
609 +
610 + $scheduled_payment_keys = [
611 + 'enable_scheduled_payments',
612 + 'scheduled_payment_type',
613 + 'scheduled_payment_days',
614 + 'scheduled_payment_installments',
615 + 'scheduled_payment_interval',
616 + 'scheduled_payment_reminder_days',
617 + 'balance_anchor',
618 + 'balance_due_days',
619 + ];
384 620
385 621 // Collect flexible payment settings to delegate to Pro
386 622 $flexible_payment_settings = [];
623 +
624 + $scheduled_payment_settings_batch = [];
387 625
388 626 // Process each setting
389 627 foreach ($data as $key => $value) {
390 628 // Skip booking_form_config if Dynamic Form Field module is not enabled
@@ -401,8 +639,15 @@
401 639 // Skip saving in Free plugin - Pro handles these
402 640 continue;
403 641 }
404 642
643 + if (in_array($key, $scheduled_payment_keys, true)) {
644 + if ($is_scheduled_payments_module) {
645 + $scheduled_payment_settings_batch[$key] = $value;
646 + }
647 + continue;
648 + }
649 +
405 650 // Wishlist toggle: only meaningful with Yatra Pro active
406 651 if ($key === 'enable_wishlist' && !apply_filters('yatra_is_pro_active', false)) {
407 652 continue;
408 653 }
@@ -441,8 +686,13 @@
441 686 do_action('yatra_save_flexible_payment_settings', $flexible_payment_settings);
442 687 $updated = array_merge($updated, array_keys($flexible_payment_settings));
443 688 }
444 689
690 + if (!empty($scheduled_payment_settings_batch) && $is_scheduled_payments_module) {
691 + do_action('yatra_save_scheduled_payment_settings', $scheduled_payment_settings_batch);
692 + $updated = array_merge($updated, array_keys($scheduled_payment_settings_batch));
693 + }
694 +
445 695 // Sync currency keys: keep 'currency' and 'default_currency' in sync
446 696 // Admin UI has both Payment Settings (currency) and Currency Settings (default_currency)
447 697 if (in_array('default_currency', $updated, true) && !in_array('currency', $updated, true)) {
448 698 $sync_currency = get_option('yatra_default_currency', 'USD');
@@ -484,12 +734,78 @@
484 734 if (!empty($updated)) {
485 735 \Yatra\Services\SettingsService::reload();
486 736 }
487 737
488 - return $this->success_response([
738 + // Cross-validation: booking-auth settings interact via OR
739 + // logic in booking-content.php, so some combinations are
740 + // semantically inconsistent or redundant. We don't block
741 + // the save (the resulting state still has well-defined
742 + // behavior), but we surface a clear notice so the operator
743 + // understands what they just configured.
744 + //
745 + // require_login=true + allow_guest_checkout=true →
746 + // require_login wins; allow_guest_checkout is a no-op.
747 + // require_login=true + allow_guest_checkout=false →
748 + // Strictest setting (login required, no guest path).
749 + // Internally consistent.
750 + // require_login=false + allow_guest_checkout=false →
751 + // Guests blocked, logged-in users can book. Consistent.
752 + // require_login=false + allow_guest_checkout=true →
753 + // Default. Permissive.
754 + $notices = [];
755 + $effective_require_login = \array_key_exists('require_login', $data)
756 + ? (bool) $data['require_login']
757 + : (bool) \Yatra\Services\SettingsService::get('require_login', false);
758 + $effective_allow_guest = \array_key_exists('allow_guest_checkout', $data)
759 + ? (bool) $data['allow_guest_checkout']
760 + : (bool) \Yatra\Services\SettingsService::get('allow_guest_checkout', true);
761 +
762 + if ($effective_require_login && $effective_allow_guest) {
763 + $notices[] = [
764 + 'level' => 'warning',
765 + 'code' => 'booking_auth_redundant',
766 + 'message' => __(
767 + '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.',
768 + 'yatra'
769 + ),
770 + ];
771 + }
772 +
773 + // Scheduled Payments + guest checkout — incompatible at
774 + // the gateway level. Scheduled charges require a saved
775 + // payment-method tied to a customer record on the
776 + // gateway side (Stripe Customer, etc.), which in turn
777 + // requires a logged-in WP user. When both settings are
778 + // on, the system gracefully skips installment creation
779 + // for guest bookings — but operators expect them to
780 + // work and only discover the gap when reconciling
781 + // unpaid bookings weeks later. Surface this proactively.
782 + $effective_scheduled_payments = \array_key_exists('enable_scheduled_payments', $data)
783 + ? (bool) $data['enable_scheduled_payments']
784 + : (bool) \Yatra\Services\SettingsService::get('enable_scheduled_payments', false);
785 + if (
786 + $effective_scheduled_payments
787 + && $effective_allow_guest
788 + && !$effective_require_login
789 + ) {
790 + $notices[] = [
791 + 'level' => 'info',
792 + 'code' => 'scheduled_payments_guest_caveat',
793 + 'message' => __(
794 + '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.',
795 + 'yatra'
796 + ),
797 + ];
798 + }
799 +
800 + $response = [
489 801 'message' => 'Settings updated successfully',
490 802 'updated' => $updated,
491 - ]);
803 + ];
804 + if ($notices !== []) {
805 + $response['notices'] = $notices;
806 + }
807 + return $this->success_response($response);
492 808 } catch (\Exception $e) {
493 809 return $this->error_response($e->getMessage(), 500);
494 810 }
495 811 }
@@ -511,8 +827,19 @@
511 827 if ($filtered_value !== null) {
512 828 return $filtered_value;
513 829 }
514 830
831 + // The booking-form config has its own structured sanitiser (field type
832 + // and width whitelists, locked core fields, text-block content, per-trip
833 + // conditions). It must run BEFORE the generic
834 + // is_array($default) branch below: that branch only text-sanitises
835 + // values and was catching this key first — because its default is [] —
836 + // so the structured sanitiser further down was never reached and any
837 + // shape at all was stored.
838 + if ($key === 'booking_form_config') {
839 + return is_array($value) ? $this->sanitize_booking_form_config($value) : [];
840 + }
841 +
515 842 // Handle null values - use default
516 843 if ($value === null) {
517 844 return $default;
518 845 }
@@ -558,12 +885,15 @@
558 885 return null;
559 886 }
560 887 $int_value = (int) $value;
561 888 // Validate ranges for specific fields
562 - if ($key === 'cancellation_days' && $int_value < 0) {
889 + if ($key === 'booking_expiry_hours' && $int_value < 0) {
563 890 return null;
564 891 }
565 - if ($key === 'booking_expiry_hours' && $int_value < 0) {
892 + // Storefront booking horizon: 1–36 months. Out of range is rejected
893 + // (not clamped) so a bad write can never blank the calendar — the
894 + // previously stored value, or the 12-month default, stays in force.
895 + if ($key === 'availability_horizon_months' && ($int_value < 1 || $int_value > 36)) {
566 896 return null;
567 897 }
568 898 if ($key === 'partial_payment_percentage' && ($int_value < 0 || $int_value > 100)) {
569 899 return null;
@@ -593,8 +923,36 @@
593 923 }
594 924
595 925 // Handle strings
596 926 if (is_string($default)) {
927 + if ($key === 'timezone') {
928 + $tz = is_string($value) ? trim($value) : '';
929 + if ($tz === '') {
930 + return is_string($default) ? $default : 'UTC';
931 + }
932 + try {
933 + new \DateTimeZone($tz);
934 +
935 + return $tz;
936 + } catch (\Exception $e) {
937 + return is_string($default) ? $default : 'UTC';
938 + }
939 + }
940 + if ($key === 'currency_position') {
941 + $allowed = ['left', 'right', 'left_space', 'right_space', 'before', 'after'];
942 + $v = is_string($value) ? strtolower(trim($value)) : '';
943 +
944 + return in_array($v, $allowed, true) ? $v : (is_string($default) ? $default : 'left');
945 + }
946 + if ($key === 'discount_stacking_mode') {
947 + // Strict enum — any other value silently falls back to the
948 + // backward-compatible default so a malformed POST cannot
949 + // change pricing behavior unexpectedly.
950 + $allowed = ['both', 'discount_only', 'dynamic_pricing_only', 'best_for_customer'];
951 + $v = is_string($value) ? strtolower(trim($value)) : '';
952 +
953 + return in_array($v, $allowed, true) ? $v : 'both';
954 + }
597 955 // Special handling for specific fields
598 956 if ($key === 'company_email' || $key === 'admin_email' || $key === 'from_email' || $key === 'smtp_username') {
599 957 return sanitize_email($value);
600 958 }
@@ -600,11 +958,8 @@
600 958 }
601 959 if ($key === 'company_website' || $key === 'company_logo' || $key === 'google_analytics' || $key === 'facebook_pixel') {
602 960 return esc_url_raw($value);
603 961 }
604 - if ($key === 'refund_policy' || $key === 'cancellation_policy') {
605 - return sanitize_textarea_field($value);
606 - }
607 962 if ($key === 'seo_trip_meta_title') {
608 963 // Allow more characters for meta title, but strip HTML
609 964 return wp_strip_all_tags($value);
610 965 }
@@ -615,8 +970,26 @@
615 970 if ($key === 'seo_trip_meta_keywords') {
616 971 // Allow keywords, strip HTML and sanitize
617 972 return sanitize_text_field($value);
618 973 }
974 + if ($key === 'frontend_primary_color') {
975 + return \Yatra\Utils\FrontendThemeCss::sanitizePrimaryColor(is_string($value) ? $value : '');
976 + }
977 + if ($key === 'frontend_container_max_width') {
978 + return \Yatra\Utils\FrontendThemeCss::sanitizeContainerMaxWidthSetting(
979 + is_string($value) ? $value : ''
980 + );
981 + }
982 + if ($key === 'frontend_listing_card_layout') {
983 + $allowed = ['standard', 'compact_mobile', 'compact_all'];
984 + $v = is_string($value) ? strtolower(trim($value)) : '';
985 + return in_array($v, $allowed, true) ? $v : 'standard';
986 + }
987 + if ($key === 'auto_confirm_mode') {
988 + $allowed = ['none', 'online', 'all'];
989 + $v = is_string($value) ? strtolower(trim($value)) : '';
990 + return in_array($v, $allowed, true) ? $v : 'online';
991 + }
619 992 if (is_string($key) && strpos($key, 'email_tpl_') === 0 && substr($key, -5) === '_body') {
620 993 return wp_kses_post((string) $value);
621 994 }
622 995 if (is_string($key) && strpos($key, 'email_tpl_') === 0 && substr($key, -8) === '_subject') {
@@ -632,15 +1005,8 @@
632 1005 return $this->sanitize_gateway_configs($value);
633 1006 }
634 1007 return [];
635 1008 }
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 1009 if ($key === 'tax_rates') {
644 1010 // Handle nested array structure for tax rates
645 1011 if (is_array($value)) {
646 1012 return $this->sanitize_tax_rates($value);
@@ -866,70 +1232,194 @@
866 1232 private function sanitize_booking_form_config(array $config): array
867 1233 {
868 1234 $sanitized = [];
869 1235 $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 -
1236 +
873 1237 foreach ($config as $form_type => $form_config) {
874 1238 if (!in_array($form_type, $allowed_form_types, true)) {
875 1239 continue;
876 1240 }
877 -
1241 +
878 1242 $sanitized[$form_type] = [
879 1243 'title' => isset($form_config['title']) ? sanitize_text_field($form_config['title']) : '',
880 1244 'description' => isset($form_config['description']) ? sanitize_text_field($form_config['description']) : '',
881 1245 'enabled' => isset($form_config['enabled']) ? (bool) $form_config['enabled'] : true,
882 - 'fields' => [],
1246 + 'fields' => $this->sanitize_booking_form_fields($form_config['fields'] ?? null, $form_type),
883 1247 ];
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;
1248 +
1249 + // Per-trip form conditions (Pro Dynamic Form Field): each condition
1250 + // is a complete alternative version of this section — its own
1251 + // title, description and field list — used on the trips it names.
1252 + // Only persisted when there is at least one, so configs saved
1253 + // without the feature stay byte-identical.
1254 + $conditions = $this->sanitize_booking_form_conditions($form_config['conditions'] ?? null, $form_type);
1255 + if ($conditions !== []) {
1256 + $sanitized[$form_type]['conditions'] = $conditions;
1257 + }
1258 + }
1259 +
1260 + return apply_filters('yatra_save_booking_form_config', $sanitized, $config);
1261 + }
1262 +
1263 + /**
1264 + * Sanitise one section's field list (global fields or a condition's fields).
1265 + *
1266 + * @param mixed $fields
1267 + * @return array<int, array<string, mixed>>
1268 + */
1269 + private function sanitize_booking_form_fields($fields, string $form_type): array
1270 + {
1271 + $allowed_field_types = ['text', 'email', 'tel', 'date', 'select', 'country', 'textarea', 'checkbox', 'number', 'text_block'];
1272 + $allowed_widths = ['full', 'half', 'third'];
1273 + $sanitized = [];
1274 +
1275 + if (empty($fields) || !is_array($fields)) {
1276 + return $sanitized;
1277 + }
1278 +
1279 + foreach ($fields as $field) {
1280 + if (!is_array($field) || empty($field['id'])) {
1281 + continue;
1282 + }
1283 +
1284 + $sanitized_field = [
1285 + 'id' => sanitize_key($field['id']),
1286 + 'type' => in_array($field['type'] ?? 'text', $allowed_field_types, true) ? $field['type'] : 'text',
1287 + 'label' => isset($field['label']) ? sanitize_text_field($field['label']) : '',
1288 + 'placeholder' => isset($field['placeholder']) ? sanitize_text_field($field['placeholder']) : '',
1289 + 'required' => isset($field['required']) ? (bool) $field['required'] : false,
1290 + 'enabled' => isset($field['enabled']) ? (bool) $field['enabled'] : true,
1291 + 'order' => isset($field['order']) ? (int) $field['order'] : 0,
1292 + 'width' => in_array($field['width'] ?? 'full', $allowed_widths, true) ? ($field['width'] ?? 'full') : 'full',
1293 + ];
1294 +
1295 + // Only persist `locked` when set: every reader treats a missing key
1296 + // as unlocked, and configs saved before this sanitiser ran never
1297 + // carried a `locked => false`, so they stay byte-identical.
1298 + if (!empty($field['locked'])) {
1299 + $sanitized_field['locked'] = true;
1300 + }
1301 +
1302 + // Handle optional section
1303 + if (!empty($field['section'])) {
1304 + $sanitized_field['section'] = sanitize_key($field['section']);
1305 + }
1306 +
1307 + // Per-traveler targeting — Traveler section only. Whitelist
1308 + // the allowed values; only persist the non-default "lead" so
1309 + // other sections and existing configs stay byte-identical.
1310 + if (
1311 + $form_type === 'traveler_form'
1312 + && ($field['applies_to'] ?? 'all') === 'lead'
1313 + ) {
1314 + $sanitized_field['applies_to'] = 'lead';
1315 + }
1316 +
1317 + // Handle options for select fields
1318 + if ($sanitized_field['type'] === 'select' && !empty($field['options']) && is_array($field['options'])) {
1319 + $sanitized_field['options'] = [];
1320 + foreach ($field['options'] as $option) {
1321 + if (is_array($option) && isset($option['value'])) {
1322 + $sanitized_field['options'][] = [
1323 + 'value' => sanitize_key($option['value']),
1324 + 'label' => isset($option['label']) ? sanitize_text_field($option['label']) : $option['value'],
1325 + ];
889 1326 }
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']);
1327 + }
1328 + }
1329 +
1330 + // A text block is display-only content placed between fields:
1331 + // keep its (safe-HTML) content, and it can never be required.
1332 + if ($sanitized_field['type'] === 'text_block') {
1333 + $sanitized_field['content'] = isset($field['content']) ? wp_kses_post($field['content']) : '';
1334 + $sanitized_field['required'] = false;
1335 + }
1336 +
1337 + // Phone fields: the country-code selector is ON by default.
1338 + // Only persist the non-default `false`, so existing configs
1339 + // (which never carried this key) stay byte-identical and read
1340 + // back as ON.
1341 + if (
1342 + $sanitized_field['type'] === 'tel'
1343 + && array_key_exists('show_country_code', $field)
1344 + && !$field['show_country_code']
1345 + ) {
1346 + $sanitized_field['show_country_code'] = false;
1347 + }
1348 +
1349 + $sanitized[] = $sanitized_field;
1350 + }
1351 +
1352 + // Sort fields by order
1353 + usort($sanitized, function ($a, $b) {
1354 + return ($a['order'] ?? 0) - ($b['order'] ?? 0);
1355 + });
1356 +
1357 + return $sanitized;
1358 + }
1359 +
1360 + /**
1361 + * Sanitise a section's per-trip conditions. A condition without any
1362 + * target (trip, category or trip type) can never match and is dropped.
1363 + *
1364 + * @param mixed $conditions
1365 + * @return array<int, array<string, mixed>>
1366 + */
1367 + private function sanitize_booking_form_conditions($conditions, string $form_type): array
1368 + {
1369 + if (empty($conditions) || !is_array($conditions)) {
1370 + return [];
1371 + }
1372 +
1373 + $allowed_trip_types = ['single_day', 'multi_day', 'flexible'];
1374 + $sanitized = [];
1375 + $n = 0;
1376 +
1377 + foreach ($conditions as $condition) {
1378 + if (!is_array($condition)) {
1379 + continue;
1380 + }
1381 + $n++;
1382 +
1383 + $raw_targets = is_array($condition['targets'] ?? null) ? $condition['targets'] : [];
1384 + $targets = [];
1385 + foreach (['trips', 'categories'] as $selector) {
1386 + $ids = array_values(array_unique(array_filter(
1387 + array_map('intval', is_array($raw_targets[$selector] ?? null) ? $raw_targets[$selector] : []),
1388 + static function ($id) {
1389 + return $id > 0;
906 1390 }
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;
1391 + )));
1392 + if ($ids !== []) {
1393 + $targets[$selector] = $ids;
922 1394 }
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 1395 }
1396 + $types = array_values(array_unique(array_filter(
1397 + array_map(static function ($t) {
1398 + return sanitize_key((string) $t);
1399 + }, is_array($raw_targets['trip_types'] ?? null) ? $raw_targets['trip_types'] : []),
1400 + static function ($t) use ($allowed_trip_types) {
1401 + return in_array($t, $allowed_trip_types, true);
1402 + }
1403 + )));
1404 + if ($types !== []) {
1405 + $targets['trip_types'] = $types;
1406 + }
1407 + if ($targets === []) {
1408 + continue;
1409 + }
1410 +
1411 + $id = sanitize_key((string) ($condition['id'] ?? ''));
1412 + $sanitized[] = [
1413 + 'id' => $id !== '' ? $id : 'condition_' . $n,
1414 + 'targets' => $targets,
1415 + 'title' => isset($condition['title']) ? sanitize_text_field($condition['title']) : '',
1416 + 'description' => isset($condition['description']) ? sanitize_text_field($condition['description']) : '',
1417 + 'fields' => $this->sanitize_booking_form_fields($condition['fields'] ?? null, $form_type),
1418 + ];
929 1419 }
930 -
931 - return apply_filters('yatra_save_booking_form_config', $sanitized, $config);
1420 +
1421 + return $sanitized;
932 1422 }
933 1423
934 1424 /**
935 1425 * Flush rewrite rules