PluginProbe
Yatra – Travel Booking & Tour Operator Software / trunk
Yatra – Travel Booking & Tour Operator Software vtrunk
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
← All changes | app/Controllers/SettingsController.php +227 -29 3.0.3trunk View file →
@@ -53,15 +53,25 @@
53 53 'auto_confirm_bookings' => false,
54 54 'auto_confirm_pay_later' => true,
55 55 'require_login' => false,
56 56 'allow_guest_checkout' => true,
57 - 'cancellation_policy' => 'full_refund',
58 - 'cancellation_days' => 7,
59 - 'refund_policy' => '',
57 + // cancellation_policy / cancellation_days / refund_policy were
58 + // removed in 3.0.5 — they only inserted text into the booking
59 + // confirmation email but did NOT enforce a cancellation cutoff
60 + // because Yatra has no customer-facing self-service
61 + // cancellation flow. Per-trip cancellation copy on the Trip
62 + // editor is the supported way to communicate policy. If those
63 + // legacy options still exist in wp_options on upgraded sites
64 + // they're harmless orphans — the save endpoint no longer
65 + // accepts them, and the email template skips the cancellation
66 + // paragraph when the global setting is absent.
60 67 'booking_expiry_hours' => 24,
61 68 'booking_reminder_days' => 3,
62 69 'allow_waitlist' => true,
63 70 'waitlist_auto_confirm' => false,
71 + // Pro: render available departure dates as a <select> instead of a
72 + // flatpickr calendar on the single-trip sidebar (desktop + mobile).
73 + 'date_picker_as_dropdown' => false,
64 74
65 75 // Payment Settings
66 76 'currency' => 'USD',
67 77 'payment_test_mode' => true,
@@ -72,9 +82,21 @@
72 82 'deposit_required' => false,
73 83 'deposit_percentage' => 20,
74 84 'gateway_configs' => [],
75 85 'gateway_order' => [],
76 -
86 +
87 + // Discount Stacking Mode — controls how the Advanced Discount and
88 + // Dynamic Pricing modules combine when both can fire on the same
89 + // booking. Default 'both' preserves the legacy stacked behavior
90 + // (discount on top of DP-adjusted price). The Settings → Pricing
91 + // tab only surfaces this setting when BOTH modules are enabled,
92 + // and CalculationService only enforces a non-default mode when
93 + // BOTH modules are loaded — so sites with only one (or neither)
94 + // module see zero behavior change.
95 + //
96 + // Allowed: 'both' | 'discount_only' | 'dynamic_pricing_only' | 'best_for_customer'
97 + 'discount_stacking_mode' => 'both',
98 +
77 99 // Scheduled/Recurring Payment Settings (Pro feature - defaults disabled)
78 100 'enable_scheduled_payments' => false,
79 101 'scheduled_payment_type' => 'single', // single, installments
80 102 'scheduled_payment_days' => 15, // Days until first scheduled payment
@@ -80,8 +102,10 @@
80 102 'scheduled_payment_days' => 15, // Days until first scheduled payment
81 103 'scheduled_payment_installments' => 1, // Number of installments (if type is installments)
82 104 'scheduled_payment_interval' => 30, // Days between installments
83 105 'scheduled_payment_reminder_days' => 3, // Days before to send reminder
106 + 'balance_anchor' => 'booking', // 'booking' (BC default) | 'tour' (relative to tour date)
107 + 'balance_due_days' => 14, // When anchor=tour: balance due this many days before the tour
84 108 'allow_save_payment_methods' => false,
85 109
86 110 // Email Settings (WordPress site defaults when Yatra options are missing)
87 111 'admin_email' => $wpAdminEmail,
@@ -86,10 +110,16 @@
86 110 // Email Settings (WordPress site defaults when Yatra options are missing)
87 111 'admin_email' => $wpAdminEmail,
88 112 'from_email' => $wpAdminEmail,
89 113 'from_name' => $wpSiteName,
114 + // Blind copy of every outgoing Yatra email, for archiving/monitoring.
115 + // Empty means no copy is sent; accepts several comma-separated addresses.
116 + 'email_always_bcc' => '',
90 117 'email_template_booking' => true,
91 118 'email_template_confirmation' => true,
119 + // Separate part-payment email. Off by default so existing sites keep
120 + // sending the single payment template for every payment.
121 + 'email_template_partial_payment' => false,
92 122 'email_template_cancellation' => true,
93 123 'email_template_reminder' => true,
94 124 'email_template_admin_new_booking' => true,
95 125 'email_template_admin_payment' => true,
@@ -95,8 +125,11 @@
95 125 'email_template_admin_payment' => true,
96 126 'email_template_admin_cancellation' => true,
97 127 'email_template_trip_consent' => true,
98 128 'email_template_customer_verification' => true,
129 + 'email_template_guest_verification' => true,
130 + 'email_template_account_email_change' => true,
131 + 'email_template_account_email_changed' => true,
99 132 'email_template_booking_completed' => true,
100 133 'email_template_booking_expired_customer' => true,
101 134 'email_template_admin_booking_expired' => true,
102 135 'email_template_scheduled_payment_reminder' => true,
@@ -106,9 +139,11 @@
106 139 'email_template_enquiry_received' => true,
107 140 'email_template_enquiry_admin' => true,
108 141 'email_template_enquiry_response' => true,
109 142 'email_template_review_request' => true,
110 - 'email_template_abandoned_booking_recovery' => true,
143 + 'email_template_abandoned_booking_recovery_first' => true,
144 + 'email_template_abandoned_booking_recovery_second' => true,
145 + 'email_template_abandoned_booking_recovery_final' => true,
111 146 'smtp_enabled' => false,
112 147 'smtp_host' => 'smtp.gmail.com',
113 148 'smtp_port' => 587,
114 149 'smtp_username' => '',
@@ -118,8 +153,13 @@
118 153 // Customer Settings
119 154 'customer_registration' => true,
120 155 'customer_fields' => [],
121 156 'require_email_verification' => false,
157 + // Per-booking verification for guest checkouts. Distinct from the
158 + // account-creation `require_email_verification` flag because a guest
159 + // never registers — the verification is gated on the booking itself
160 + // (BookingSessionController checks this when admitting a guest).
161 + 'require_guest_email_verification' => false,
122 162 'customer_account_page' => '',
123 163 'allow_customer_reviews' => true,
124 164 'customer_dashboard_enabled' => true,
125 165
@@ -162,9 +202,16 @@
162 202 'facebook_pixel' => '',
163 203 'recaptcha_enabled' => false,
164 204 'recaptcha_site_key' => '',
165 205 'recaptcha_secret_key' => '',
166 -
206 + // reCAPTCHA v3: score threshold (0.0-1.0) + per-form protection toggles.
207 + // All off by default so enabling reCAPTCHA alone changes nothing until
208 + // the operator picks which forms to protect.
209 + 'recaptcha_score_threshold' => 0.5,
210 + 'recaptcha_protect_enquiry' => false,
211 + 'recaptcha_protect_booking' => false,
212 + 'recaptcha_protect_registration' => false,
213 +
167 214 // Permalink Settings
168 215 'trip_base' => 'trip',
169 216 'destination_base' => 'destination',
170 217 'activity_base' => 'activity',
@@ -171,9 +218,29 @@
171 218 'trip_category_base' => 'trip-category',
172 219 'booking_base' => 'book',
173 220 // Wishlist (Pro) — stored in free options; active only when Pro + setting on
174 221 'enable_wishlist' => false,
175 -
222 + // Sold-out date visibility on the storefront. Default true keeps the
223 + // existing behaviour (sold-out dates stay visible, badged "sold out" and
224 + // able to drive the waitlist); owners can switch it off to hide them the
225 + // same way blocked dates are hidden.
226 + 'show_sold_out' => true,
227 +
228 + // Search & Listing storefront UX. Defaults preserve current behaviour:
229 + // every search field shown (true) and mobile filters expanded (false),
230 + // so existing installs are unchanged until the owner opts in. Booleans
231 + // are auto-sanitized from the default type.
232 + 'search_show_keyword' => true,
233 + 'search_show_destination' => true,
234 + 'search_show_activities' => true,
235 + 'search_show_duration' => true,
236 + 'search_show_budget' => true,
237 + // Date field is opt-in (default false) so updating the plugin never
238 + // changes an existing site's search bar. Operators enable it to let
239 + // customers find trips with a departure on a specific date.
240 + 'search_show_date' => false,
241 + 'collapse_filters_on_mobile' => false,
242 +
176 243 // Booking Page Settings
177 244 'use_booking_page' => false,
178 245 'booking_page_id' => 0,
179 246
@@ -185,9 +252,10 @@
185 252 'seo_trip_meta_title' => '',
186 253 'seo_trip_meta_description' => '',
187 254 'seo_trip_meta_keywords' => '',
188 255 'seo_trip_meta_image' => 0,
189 -
256 + 'enable_sitemap' => true,
257 +
190 258 // Advanced Settings
191 259 'debug_mode' => false,
192 260 'enable_logging' => false,
193 261 'cache_enabled' => true,
@@ -303,17 +371,22 @@
303 371 return $this->error_response($e->getMessage(), 500);
304 372 }
305 373 }
306 374
375 + /**
376 + * Plugin settings — high-sensitivity cap. By default only the
377 + * Owner role holds `yatra_manage_settings` (Manager doesn't, by
378 + * design — settings include payment gateway routing, email
379 + * delivery configuration, currency formatting and similar
380 + * global behaviour). WP admins pass via the Team module's
381 + * admin-fallback filter.
382 + */
307 383 public function check_permission(?WP_REST_Request $request = null): bool
308 384 {
309 385 if (!is_user_logged_in()) {
310 386 return false;
311 387 }
312 -
313 - // Match other Yatra admin surfaces (e.g. Email Automation, Pro modules)
314 - return current_user_can('manage_options')
315 - || current_user_can('manage_yatra');
388 + return current_user_can('yatra_manage_settings');
316 389 }
317 390
318 391 /**
319 392 * Get all settings
@@ -322,15 +395,23 @@
322 395 {
323 396 try {
324 397 $settings = [];
325 398
326 - // Get all settings from WordPress options table with yatra_ prefix
399 + // Get all settings from WordPress options table with yatra_ prefix.
400 + // A sentinel default is essential here: get_option() returns boolean
401 + // false for a stored-false option just as it does for a missing one,
402 + // so checking `=== false` would reset every saved-off boolean back to
403 + // its default. That is exactly the "Show sold-out dates" bug — the
404 + // storefront honoured the saved value (isEnabled coerces '' -> false)
405 + // while the admin checkbox re-appeared enabled because this endpoint
406 + // handed React the default (true) instead of the saved false.
407 + $unset_sentinel = "\0__yatra_option_unset__\0";
327 408 foreach ($this->default_settings as $key => $default_value) {
328 409 $option_name = 'yatra_' . $key;
329 - $value = get_option($option_name, false);
330 -
331 - // Only use default if option doesn't exist (wasn't set by InstallerService)
332 - if ($value === false) {
410 + $value = get_option($option_name, $unset_sentinel);
411 +
412 + // Only use default when the option truly does not exist.
413 + if ($value === $unset_sentinel) {
333 414 $value = $default_value;
334 415 }
335 416
336 417 // Stored empty string should behave like "unset" for delivery identity (matches installer / backfill).
@@ -351,9 +432,18 @@
351 432 // Ensure arrays are returned as arrays (not objects)
352 433 if (is_array($default_value) && !is_array($value)) {
353 434 $value = [];
354 435 }
355 -
436 +
437 + // Boolean settings must round-trip to the admin as real booleans.
438 + // update_option() stores false as '' and the object cache can
439 + // return boolean false, so without this a disabled toggle would
440 + // reach React as '' / false and the checkbox (checked unless the
441 + // value is strictly !== false) would render enabled again.
442 + if (is_bool($default_value)) {
443 + $value = filter_var($value, FILTER_VALIDATE_BOOLEAN);
444 + }
445 +
356 446 $settings[$key] = $value;
357 447 }
358 448
359 449 // Special handling for booking_form_config - always use getBookingFormConfig which handles locked fields
@@ -378,8 +468,10 @@
378 468 'scheduled_payment_days',
379 469 'scheduled_payment_installments',
380 470 'scheduled_payment_interval',
381 471 'scheduled_payment_reminder_days',
472 + 'balance_anchor',
473 + 'balance_due_days',
382 474 ] as $sk
383 475 ) {
384 476 if (array_key_exists($sk, $this->default_settings)) {
385 477 $settings[$sk] = \Yatra\Services\SettingsService::get(
@@ -390,8 +482,14 @@
390 482 }
391 483
392 484 $settings = $this->syncAccountRouteSettingsForResponse($settings);
393 485
486 + /**
487 + * Allow Pro modules to align REST payloads with canonical option stores
488 + * (e.g. GA4 settings that also live in yatra_google_analytics_settings).
489 + */
490 + $settings = apply_filters('yatra_rest_settings', $settings);
491 +
394 492 return $this->success_response($settings);
395 493 } catch (\Exception $e) {
396 494 return $this->error_response($e->getMessage(), 500);
397 495 }
@@ -432,8 +530,10 @@
432 530 'scheduled_payment_days',
433 531 'scheduled_payment_installments',
434 532 'scheduled_payment_interval',
435 533 'scheduled_payment_reminder_days',
534 + 'balance_anchor',
535 + 'balance_due_days',
436 536 ];
437 537
438 538 // Collect flexible payment settings to delegate to Pro
439 539 $flexible_payment_settings = [];
@@ -551,12 +651,78 @@
551 651 if (!empty($updated)) {
552 652 \Yatra\Services\SettingsService::reload();
553 653 }
554 654
555 - return $this->success_response([
655 + // Cross-validation: booking-auth settings interact via OR
656 + // logic in booking-content.php, so some combinations are
657 + // semantically inconsistent or redundant. We don't block
658 + // the save (the resulting state still has well-defined
659 + // behavior), but we surface a clear notice so the operator
660 + // understands what they just configured.
661 + //
662 + // require_login=true + allow_guest_checkout=true →
663 + // require_login wins; allow_guest_checkout is a no-op.
664 + // require_login=true + allow_guest_checkout=false →
665 + // Strictest setting (login required, no guest path).
666 + // Internally consistent.
667 + // require_login=false + allow_guest_checkout=false →
668 + // Guests blocked, logged-in users can book. Consistent.
669 + // require_login=false + allow_guest_checkout=true →
670 + // Default. Permissive.
671 + $notices = [];
672 + $effective_require_login = \array_key_exists('require_login', $data)
673 + ? (bool) $data['require_login']
674 + : (bool) \Yatra\Services\SettingsService::get('require_login', false);
675 + $effective_allow_guest = \array_key_exists('allow_guest_checkout', $data)
676 + ? (bool) $data['allow_guest_checkout']
677 + : (bool) \Yatra\Services\SettingsService::get('allow_guest_checkout', true);
678 +
679 + if ($effective_require_login && $effective_allow_guest) {
680 + $notices[] = [
681 + 'level' => 'warning',
682 + 'code' => 'booking_auth_redundant',
683 + 'message' => __(
684 + '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.',
685 + 'yatra'
686 + ),
687 + ];
688 + }
689 +
690 + // Scheduled Payments + guest checkout — incompatible at
691 + // the gateway level. Scheduled charges require a saved
692 + // payment-method tied to a customer record on the
693 + // gateway side (Stripe Customer, etc.), which in turn
694 + // requires a logged-in WP user. When both settings are
695 + // on, the system gracefully skips installment creation
696 + // for guest bookings — but operators expect them to
697 + // work and only discover the gap when reconciling
698 + // unpaid bookings weeks later. Surface this proactively.
699 + $effective_scheduled_payments = \array_key_exists('enable_scheduled_payments', $data)
700 + ? (bool) $data['enable_scheduled_payments']
701 + : (bool) \Yatra\Services\SettingsService::get('enable_scheduled_payments', false);
702 + if (
703 + $effective_scheduled_payments
704 + && $effective_allow_guest
705 + && !$effective_require_login
706 + ) {
707 + $notices[] = [
708 + 'level' => 'info',
709 + 'code' => 'scheduled_payments_guest_caveat',
710 + 'message' => __(
711 + '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.',
712 + 'yatra'
713 + ),
714 + ];
715 + }
716 +
717 + $response = [
556 718 'message' => 'Settings updated successfully',
557 719 'updated' => $updated,
558 - ]);
720 + ];
721 + if ($notices !== []) {
722 + $response['notices'] = $notices;
723 + }
724 + return $this->success_response($response);
559 725 } catch (\Exception $e) {
560 726 return $this->error_response($e->getMessage(), 500);
561 727 }
562 728 }
@@ -625,11 +791,8 @@
625 791 return null;
626 792 }
627 793 $int_value = (int) $value;
628 794 // Validate ranges for specific fields
629 - if ($key === 'cancellation_days' && $int_value < 0) {
630 - return null;
631 - }
632 795 if ($key === 'booking_expiry_hours' && $int_value < 0) {
633 796 return null;
634 797 }
635 798 if ($key === 'partial_payment_percentage' && ($int_value < 0 || $int_value > 100)) {
@@ -679,8 +842,17 @@
679 842 $v = is_string($value) ? strtolower(trim($value)) : '';
680 843
681 844 return in_array($v, $allowed, true) ? $v : (is_string($default) ? $default : 'left');
682 845 }
846 + if ($key === 'discount_stacking_mode') {
847 + // Strict enum — any other value silently falls back to the
848 + // backward-compatible default so a malformed POST cannot
849 + // change pricing behavior unexpectedly.
850 + $allowed = ['both', 'discount_only', 'dynamic_pricing_only', 'best_for_customer'];
851 + $v = is_string($value) ? strtolower(trim($value)) : '';
852 +
853 + return in_array($v, $allowed, true) ? $v : 'both';
854 + }
683 855 // Special handling for specific fields
684 856 if ($key === 'company_email' || $key === 'admin_email' || $key === 'from_email' || $key === 'smtp_username') {
685 857 return sanitize_email($value);
686 858 }
@@ -686,11 +858,8 @@
686 858 }
687 859 if ($key === 'company_website' || $key === 'company_logo' || $key === 'google_analytics' || $key === 'facebook_pixel') {
688 860 return esc_url_raw($value);
689 861 }
690 - if ($key === 'refund_policy' || $key === 'cancellation_policy') {
691 - return sanitize_textarea_field($value);
692 - }
693 862 if ($key === 'seo_trip_meta_title') {
694 863 // Allow more characters for meta title, but strip HTML
695 864 return wp_strip_all_tags($value);
696 865 }
@@ -960,9 +1129,9 @@
960 1129 private function sanitize_booking_form_config(array $config): array
961 1130 {
962 1131 $sanitized = [];
963 1132 $allowed_form_types = ['contact_form', 'emergency_contact_form', 'traveler_form'];
964 - $allowed_field_types = ['text', 'email', 'tel', 'date', 'select', 'country', 'textarea', 'checkbox', 'number'];
1133 + $allowed_field_types = ['text', 'email', 'tel', 'date', 'select', 'country', 'textarea', 'checkbox', 'number', 'text_block'];
965 1134 $allowed_widths = ['full', 'half', 'third'];
966 1135
967 1136 foreach ($config as $form_type => $form_config) {
968 1137 if (!in_array($form_type, $allowed_form_types, true)) {
@@ -989,9 +1158,9 @@
989 1158 'placeholder' => isset($field['placeholder']) ? sanitize_text_field($field['placeholder']) : '',
990 1159 'required' => isset($field['required']) ? (bool) $field['required'] : false,
991 1160 'enabled' => isset($field['enabled']) ? (bool) $field['enabled'] : true,
992 1161 'order' => isset($field['order']) ? (int) $field['order'] : 0,
993 - 'width' => in_array($field['width'] ?? 'full', $allowed_widths, true) ? $field['width'] : 'full',
1162 + 'width' => in_array($field['width'] ?? 'full', $allowed_widths, true) ? ($field['width'] ?? 'full') : 'full',
994 1163 'locked' => isset($field['locked']) ? (bool) $field['locked'] : false,
995 1164 ];
996 1165
997 1166 // Handle optional section
@@ -997,8 +1166,18 @@
997 1166 // Handle optional section
998 1167 if (!empty($field['section'])) {
999 1168 $sanitized_field['section'] = sanitize_key($field['section']);
1000 1169 }
1170 +
1171 + // Per-traveler targeting — Traveler section only. Whitelist
1172 + // the allowed values; only persist the non-default "lead" so
1173 + // other sections and existing configs stay byte-identical.
1174 + if (
1175 + $form_type === 'traveler_form'
1176 + && ($field['applies_to'] ?? 'all') === 'lead'
1177 + ) {
1178 + $sanitized_field['applies_to'] = 'lead';
1179 + }
1001 1180
1002 1181 // Handle options for select fields
1003 1182 if ($sanitized_field['type'] === 'select' && !empty($field['options']) && is_array($field['options'])) {
1004 1183 $sanitized_field['options'] = [];
@@ -1010,9 +1189,28 @@
1010 1189 ];
1011 1190 }
1012 1191 }
1013 1192 }
1014 -
1193 +
1194 + // A text block is display-only content placed between fields:
1195 + // keep its (safe-HTML) content, and it can never be required.
1196 + if ($sanitized_field['type'] === 'text_block') {
1197 + $sanitized_field['content'] = isset($field['content']) ? wp_kses_post($field['content']) : '';
1198 + $sanitized_field['required'] = false;
1199 + }
1200 +
1201 + // Phone fields: the country-code selector is ON by default.
1202 + // Only persist the non-default `false`, so existing configs
1203 + // (which never carried this key) stay byte-identical and read
1204 + // back as ON.
1205 + if (
1206 + $sanitized_field['type'] === 'tel'
1207 + && array_key_exists('show_country_code', $field)
1208 + && !$field['show_country_code']
1209 + ) {
1210 + $sanitized_field['show_country_code'] = false;
1211 + }
1212 +
1015 1213 $sanitized[$form_type]['fields'][] = $sanitized_field;
1016 1214 }
1017 1215
1018 1216 // Sort fields by order