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 +215 -28 3.0.4trunk View file →
@@ -53,11 +53,18 @@
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,
@@ -75,9 +82,21 @@
75 82 'deposit_required' => false,
76 83 'deposit_percentage' => 20,
77 84 'gateway_configs' => [],
78 85 'gateway_order' => [],
79 -
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 +
80 99 // Scheduled/Recurring Payment Settings (Pro feature - defaults disabled)
81 100 'enable_scheduled_payments' => false,
82 101 'scheduled_payment_type' => 'single', // single, installments
83 102 'scheduled_payment_days' => 15, // Days until first scheduled payment
@@ -83,8 +102,10 @@
83 102 'scheduled_payment_days' => 15, // Days until first scheduled payment
84 103 'scheduled_payment_installments' => 1, // Number of installments (if type is installments)
85 104 'scheduled_payment_interval' => 30, // Days between installments
86 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
87 108 'allow_save_payment_methods' => false,
88 109
89 110 // Email Settings (WordPress site defaults when Yatra options are missing)
90 111 'admin_email' => $wpAdminEmail,
@@ -89,10 +110,16 @@
89 110 // Email Settings (WordPress site defaults when Yatra options are missing)
90 111 'admin_email' => $wpAdminEmail,
91 112 'from_email' => $wpAdminEmail,
92 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' => '',
93 117 'email_template_booking' => true,
94 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,
95 122 'email_template_cancellation' => true,
96 123 'email_template_reminder' => true,
97 124 'email_template_admin_new_booking' => true,
98 125 'email_template_admin_payment' => true,
@@ -98,8 +125,11 @@
98 125 'email_template_admin_payment' => true,
99 126 'email_template_admin_cancellation' => true,
100 127 'email_template_trip_consent' => true,
101 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,
102 132 'email_template_booking_completed' => true,
103 133 'email_template_booking_expired_customer' => true,
104 134 'email_template_admin_booking_expired' => true,
105 135 'email_template_scheduled_payment_reminder' => true,
@@ -123,8 +153,13 @@
123 153 // Customer Settings
124 154 'customer_registration' => true,
125 155 'customer_fields' => [],
126 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,
127 162 'customer_account_page' => '',
128 163 'allow_customer_reviews' => true,
129 164 'customer_dashboard_enabled' => true,
130 165
@@ -167,9 +202,16 @@
167 202 'facebook_pixel' => '',
168 203 'recaptcha_enabled' => false,
169 204 'recaptcha_site_key' => '',
170 205 'recaptcha_secret_key' => '',
171 -
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 +
172 214 // Permalink Settings
173 215 'trip_base' => 'trip',
174 216 'destination_base' => 'destination',
175 217 'activity_base' => 'activity',
@@ -176,9 +218,29 @@
176 218 'trip_category_base' => 'trip-category',
177 219 'booking_base' => 'book',
178 220 // Wishlist (Pro) — stored in free options; active only when Pro + setting on
179 221 'enable_wishlist' => false,
180 -
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 +
181 243 // Booking Page Settings
182 244 'use_booking_page' => false,
183 245 'booking_page_id' => 0,
184 246
@@ -190,9 +252,10 @@
190 252 'seo_trip_meta_title' => '',
191 253 'seo_trip_meta_description' => '',
192 254 'seo_trip_meta_keywords' => '',
193 255 'seo_trip_meta_image' => 0,
194 -
256 + 'enable_sitemap' => true,
257 +
195 258 // Advanced Settings
196 259 'debug_mode' => false,
197 260 'enable_logging' => false,
198 261 'cache_enabled' => true,
@@ -308,17 +371,22 @@
308 371 return $this->error_response($e->getMessage(), 500);
309 372 }
310 373 }
311 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 + */
312 383 public function check_permission(?WP_REST_Request $request = null): bool
313 384 {
314 385 if (!is_user_logged_in()) {
315 386 return false;
316 387 }
317 -
318 - // Match other Yatra admin surfaces (e.g. Email Automation, Pro modules)
319 - return current_user_can('manage_options')
320 - || current_user_can('manage_yatra');
388 + return current_user_can('yatra_manage_settings');
321 389 }
322 390
323 391 /**
324 392 * Get all settings
@@ -327,15 +395,23 @@
327 395 {
328 396 try {
329 397 $settings = [];
330 398
331 - // 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";
332 408 foreach ($this->default_settings as $key => $default_value) {
333 409 $option_name = 'yatra_' . $key;
334 - $value = get_option($option_name, false);
335 -
336 - // Only use default if option doesn't exist (wasn't set by InstallerService)
337 - 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) {
338 414 $value = $default_value;
339 415 }
340 416
341 417 // Stored empty string should behave like "unset" for delivery identity (matches installer / backfill).
@@ -356,9 +432,18 @@
356 432 // Ensure arrays are returned as arrays (not objects)
357 433 if (is_array($default_value) && !is_array($value)) {
358 434 $value = [];
359 435 }
360 -
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 +
361 446 $settings[$key] = $value;
362 447 }
363 448
364 449 // Special handling for booking_form_config - always use getBookingFormConfig which handles locked fields
@@ -383,8 +468,10 @@
383 468 'scheduled_payment_days',
384 469 'scheduled_payment_installments',
385 470 'scheduled_payment_interval',
386 471 'scheduled_payment_reminder_days',
472 + 'balance_anchor',
473 + 'balance_due_days',
387 474 ] as $sk
388 475 ) {
389 476 if (array_key_exists($sk, $this->default_settings)) {
390 477 $settings[$sk] = \Yatra\Services\SettingsService::get(
@@ -443,8 +530,10 @@
443 530 'scheduled_payment_days',
444 531 'scheduled_payment_installments',
445 532 'scheduled_payment_interval',
446 533 'scheduled_payment_reminder_days',
534 + 'balance_anchor',
535 + 'balance_due_days',
447 536 ];
448 537
449 538 // Collect flexible payment settings to delegate to Pro
450 539 $flexible_payment_settings = [];
@@ -562,12 +651,78 @@
562 651 if (!empty($updated)) {
563 652 \Yatra\Services\SettingsService::reload();
564 653 }
565 654
566 - 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 = [
567 718 'message' => 'Settings updated successfully',
568 719 'updated' => $updated,
569 - ]);
720 + ];
721 + if ($notices !== []) {
722 + $response['notices'] = $notices;
723 + }
724 + return $this->success_response($response);
570 725 } catch (\Exception $e) {
571 726 return $this->error_response($e->getMessage(), 500);
572 727 }
573 728 }
@@ -636,11 +791,8 @@
636 791 return null;
637 792 }
638 793 $int_value = (int) $value;
639 794 // Validate ranges for specific fields
640 - if ($key === 'cancellation_days' && $int_value < 0) {
641 - return null;
642 - }
643 795 if ($key === 'booking_expiry_hours' && $int_value < 0) {
644 796 return null;
645 797 }
646 798 if ($key === 'partial_payment_percentage' && ($int_value < 0 || $int_value > 100)) {
@@ -690,8 +842,17 @@
690 842 $v = is_string($value) ? strtolower(trim($value)) : '';
691 843
692 844 return in_array($v, $allowed, true) ? $v : (is_string($default) ? $default : 'left');
693 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 + }
694 855 // Special handling for specific fields
695 856 if ($key === 'company_email' || $key === 'admin_email' || $key === 'from_email' || $key === 'smtp_username') {
696 857 return sanitize_email($value);
697 858 }
@@ -697,11 +858,8 @@
697 858 }
698 859 if ($key === 'company_website' || $key === 'company_logo' || $key === 'google_analytics' || $key === 'facebook_pixel') {
699 860 return esc_url_raw($value);
700 861 }
701 - if ($key === 'refund_policy' || $key === 'cancellation_policy') {
702 - return sanitize_textarea_field($value);
703 - }
704 862 if ($key === 'seo_trip_meta_title') {
705 863 // Allow more characters for meta title, but strip HTML
706 864 return wp_strip_all_tags($value);
707 865 }
@@ -971,9 +1129,9 @@
971 1129 private function sanitize_booking_form_config(array $config): array
972 1130 {
973 1131 $sanitized = [];
974 1132 $allowed_form_types = ['contact_form', 'emergency_contact_form', 'traveler_form'];
975 - $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'];
976 1134 $allowed_widths = ['full', 'half', 'third'];
977 1135
978 1136 foreach ($config as $form_type => $form_config) {
979 1137 if (!in_array($form_type, $allowed_form_types, true)) {
@@ -1000,9 +1158,9 @@
1000 1158 'placeholder' => isset($field['placeholder']) ? sanitize_text_field($field['placeholder']) : '',
1001 1159 'required' => isset($field['required']) ? (bool) $field['required'] : false,
1002 1160 'enabled' => isset($field['enabled']) ? (bool) $field['enabled'] : true,
1003 1161 'order' => isset($field['order']) ? (int) $field['order'] : 0,
1004 - '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',
1005 1163 'locked' => isset($field['locked']) ? (bool) $field['locked'] : false,
1006 1164 ];
1007 1165
1008 1166 // Handle optional section
@@ -1008,8 +1166,18 @@
1008 1166 // Handle optional section
1009 1167 if (!empty($field['section'])) {
1010 1168 $sanitized_field['section'] = sanitize_key($field['section']);
1011 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 + }
1012 1180
1013 1181 // Handle options for select fields
1014 1182 if ($sanitized_field['type'] === 'select' && !empty($field['options']) && is_array($field['options'])) {
1015 1183 $sanitized_field['options'] = [];
@@ -1021,9 +1189,28 @@
1021 1189 ];
1022 1190 }
1023 1191 }
1024 1192 }
1025 -
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 +
1026 1213 $sanitized[$form_type]['fields'][] = $sanitized_field;
1027 1214 }
1028 1215
1029 1216 // Sort fields by order