PluginProbe
Yatra – Travel Booking & Tour Operator Software / 3.0.15
Yatra – Travel Booking & Tour Operator Software v3.0.15
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 2.0.10 All 83 releases
yatra / app / Controllers / SettingsController.php

SettingsController.php in Yatra – Travel Booking & Tour Operator Software 3.0.15, at app/Controllers/SettingsController.php

1,596 lines 67.9 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 declare(strict_types=1);
4
5 namespace Yatra\Controllers;
6
7 use WP_REST_Request;
8 use WP_REST_Response;
9 use WP_Error;
10 use Yatra\Services\EmailTemplatePreviewService;
11
12 /**
13 * Settings REST API Controller
14 * Handles getting and updating plugin settings stored in WordPress options table
15 */
16 class SettingsController extends BaseController
17 {
18 /**
19 * All settings fields with their default values
20 * Pro plugin can add additional settings via filter
21 */
22 private array $default_settings;
23
24 /**
25 * Constructor - initialize default settings with filter
26 */
27 public function __construct()
28 {
29 $wpAdminEmail = (string) get_option('admin_email', '');
30 $wpSiteName = (string) get_bloginfo('name');
31
32 // Define base settings
33 $base_settings = [
34 // General Settings
35 'company_name' => '',
36 'company_email' => '',
37 'company_phone' => '',
38 'company_address' => '',
39 'company_city' => '',
40 'company_state' => '',
41 'company_country' => '',
42 'company_zip' => '',
43 'company_website' => '',
44 'company_logo' => '',
45 'timezone' => 'UTC',
46 'date_format' => 'Y-m-d',
47 'time_format' => 'H:i',
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
55 // Booking Settings
56 'booking_confirmation' => true,
57 // Legacy boolean, kept for backward compatibility. Superseded by
58 // 'auto_confirm_mode' below; the mode is authoritative once stored.
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',
66 'auto_confirm_pay_later' => true,
67 'require_login' => false,
68 'allow_guest_checkout' => true,
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.
79 'booking_expiry_hours' => 24,
80 'booking_reminder_days' => 3,
81 'availability_horizon_months' => 12,
82 'allow_waitlist' => true,
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,
87
88 // Payment Settings
89 'currency' => 'USD',
90 'payment_test_mode' => true,
91 'payment_gateways' => [],
92 'payment_methods' => [],
93 'partial_payment' => false,
94 'partial_payment_percentage' => 30,
95 'deposit_required' => false,
96 'deposit_percentage' => 20,
97 'gateway_configs' => [],
98 'gateway_order' => [],
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
112 // Scheduled/Recurring Payment Settings (Pro feature - defaults disabled)
113 'enable_scheduled_payments' => false,
114 'scheduled_payment_type' => 'single', // single, installments
115 'scheduled_payment_days' => 15, // Days until first scheduled payment
116 'scheduled_payment_installments' => 1, // Number of installments (if type is installments)
117 'scheduled_payment_interval' => 30, // Days between installments
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
121 'allow_save_payment_methods' => false,
122
123 // Email Settings (WordPress site defaults when Yatra options are missing)
124 'admin_email' => $wpAdminEmail,
125 'from_email' => $wpAdminEmail,
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' => '',
130 'email_template_booking' => true,
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,
135 'email_template_cancellation' => true,
136 'email_template_reminder' => true,
137 'email_template_admin_new_booking' => true,
138 'email_template_admin_payment' => true,
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,
159 'smtp_enabled' => false,
160 'smtp_host' => 'smtp.gmail.com',
161 'smtp_port' => 587,
162 'smtp_username' => '',
163 'smtp_password' => '',
164 'smtp_encryption' => 'tls',
165
166 // Customer Settings
167 'customer_registration' => true,
168 'customer_fields' => [],
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,
175 'customer_account_page' => '',
176 'allow_customer_reviews' => true,
177 'customer_dashboard_enabled' => true,
178
179 // Review Settings
180 'enable_reviews' => true,
181 'require_booking' => true,
182 'auto_approve_reviews' => false,
183 'review_moderation' => true,
184 'min_rating' => 1,
185 'allow_anonymous_reviews' => false,
186 'review_reminder_days' => 7,
187
188 // Tax Settings
189 'enable_tax' => false,
190 'tax_name' => __('Tax', 'yatra'),
191 'tax_rate' => 0,
192 'tax_inclusive' => false,
193 'vat_number' => '',
194 'tax_by_country' => false,
195 'tax_rates' => [],
196 'multiple_taxes_enabled' => false,
197 'multiple_taxes' => [],
198 'multiple_taxes_by_country' => [],
199
200 // Currency Settings
201 'default_currency' => 'USD',
202 'multi_currency' => false,
203 'currency_position' => 'left',
204 'currency_decimals' => 2,
205 'decimal_separator'=>'.',
206 'thousand_separator'=>',',
207
208 // Notification Settings (SMS / future channels — booking email toggles live under Email → Templates)
209 'sms_notifications' => false,
210 'sms_provider' => '',
211 'sms_api_key' => '',
212
213 // Integration Settings
214 'google_analytics' => '',
215 'facebook_pixel' => '',
216 'recaptcha_enabled' => false,
217 'recaptcha_site_key' => '',
218 'recaptcha_secret_key' => '',
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
227 // Permalink Settings
228 'trip_base' => 'trip',
229 'destination_base' => 'destination',
230 'activity_base' => 'activity',
231 'trip_category_base' => 'trip-category',
232 'booking_base' => 'book',
233 // Wishlist (Pro) — stored in free options; active only when Pro + setting on
234 'enable_wishlist' => false,
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
256 // Booking Page Settings
257 'use_booking_page' => false,
258 'booking_page_id' => 0,
259
260 // Legal Pages (Booking UI)
261 'terms_page_id' => 0,
262 'privacy_policy_page_id' => 0,
263
264 // SEO Settings
265 'seo_trip_meta_title' => '',
266 'seo_trip_meta_description' => '',
267 'seo_trip_meta_keywords' => '',
268 'seo_trip_meta_image' => 0,
269 'enable_sitemap' => true,
270
271 // Advanced Settings
272 'debug_mode' => false,
273 'enable_logging' => false,
274 'cache_enabled' => true,
275 'api_key' => '',
276 'api_rate_limit' => 100,
277 'session_timeout' => 3600,
278
279 // Booking Form Builder
280 'booking_form_config' => [],
281 ];
282
283 $base_settings = array_merge(
284 $base_settings,
285 \Yatra\Services\EmailTemplateDefaults::settingsOptionDefaults()
286 );
287
288 // Allow Pro plugins to add their settings via filter
289 $this->default_settings = apply_filters('yatra_settings_default_fields', $base_settings);
290 }
291
292 public function register_routes(): void
293 {
294 $namespace = 'yatra/v1';
295 $base = 'settings';
296
297 register_rest_route($namespace, '/' . $base, [
298 [
299 'methods' => \WP_REST_Server::READABLE,
300 'callback' => [$this, 'get_settings'],
301 'permission_callback' => [$this, 'check_permission'],
302 ],
303 [
304 'methods' => \WP_REST_Server::EDITABLE,
305 'callback' => [$this, 'update_settings'],
306 'permission_callback' => [$this, 'check_permission'],
307 ],
308 ]);
309
310 // Flush rewrite rules endpoint
311 register_rest_route($namespace, '/' . $base . '/flush-rewrite-rules', [
312 [
313 'methods' => \WP_REST_Server::CREATABLE,
314 'callback' => [$this, 'flush_rewrite_rules'],
315 'permission_callback' => [$this, 'check_permission'],
316 ],
317 ]);
318
319 // Booking form config, optionally resolved for one trip (Pro form
320 // conditions). Readable by anyone who can view bookings, so the
321 // booking detail screen can label the fields a trip actually asked.
322 register_rest_route($namespace, '/' . $base . '/booking-form', [
323 [
324 'methods' => \WP_REST_Server::READABLE,
325 'callback' => [$this, 'get_booking_form_config'],
326 'permission_callback' => [$this, 'check_booking_form_permission'],
327 'args' => [
328 'trip_id' => [
329 'type' => 'integer',
330 'required' => false,
331 'sanitize_callback' => 'absint',
332 ],
333 ],
334 ],
335 ]);
336
337 // Get WordPress pages for booking page selection
338 register_rest_route($namespace, '/' . $base . '/pages', [
339 [
340 'methods' => \WP_REST_Server::READABLE,
341 'callback' => [$this, 'get_pages'],
342 'permission_callback' => [$this, 'check_permission'],
343 ],
344 ]);
345
346 // Check if page has booking shortcode
347 register_rest_route($namespace, '/' . $base . '/check-shortcode/(?P<page_id>\d+)', [
348 [
349 'methods' => \WP_REST_Server::READABLE,
350 'callback' => [$this, 'check_booking_shortcode'],
351 'permission_callback' => [$this, 'check_permission'],
352 ],
353 ]);
354
355 // Insert booking shortcode into page
356 register_rest_route($namespace, '/' . $base . '/insert-shortcode/(?P<page_id>\d+)', [
357 [
358 'methods' => \WP_REST_Server::CREATABLE,
359 'callback' => [$this, 'insert_booking_shortcode'],
360 'permission_callback' => [$this, 'check_permission'],
361 ],
362 ]);
363
364 register_rest_route($namespace, '/' . $base . '/email-template-preview', [
365 [
366 'methods' => \WP_REST_Server::CREATABLE,
367 'callback' => [$this, 'preview_core_email_template'],
368 'permission_callback' => [$this, 'check_permission'],
369 ],
370 ]);
371 }
372
373 /**
374 * Preview a core (settings-backed) transactional template with sample merge data.
375 */
376 public function preview_core_email_template(WP_REST_Request $request)
377 {
378 try {
379 $params = $request->get_json_params();
380 if (!is_array($params)) {
381 return $this->error_response(__('Invalid request body.', 'yatra'), 400);
382 }
383
384 $templateKey = sanitize_key($params['template_key'] ?? '');
385 $subjectTpl = sanitize_text_field($params['subject'] ?? '');
386 $bodyTpl = wp_kses_post($params['body'] ?? '');
387 $tripId = isset($params['trip_id']) ? (int) $params['trip_id'] : 0;
388 $tripId = $tripId > 0 ? $tripId : null;
389
390 $rendered = EmailTemplatePreviewService::render($templateKey, $subjectTpl, $bodyTpl, $tripId);
391
392 return $this->success_response([
393 'success' => true,
394 'data' => [
395 'subject' => $rendered['subject'],
396 'body' => $rendered['body'],
397 ],
398 ]);
399 } catch (\InvalidArgumentException $e) {
400 return $this->error_response($e->getMessage(), 400);
401 } catch (\Exception $e) {
402 return $this->error_response($e->getMessage(), 500);
403 }
404 }
405
406 /**
407 * Plugin settings — high-sensitivity cap. By default only the
408 * Owner role holds `yatra_manage_settings` (Manager doesn't, by
409 * design — settings include payment gateway routing, email
410 * delivery configuration, currency formatting and similar
411 * global behaviour). WP admins pass via the Team module's
412 * admin-fallback filter.
413 */
414 public function check_permission(?WP_REST_Request $request = null): bool
415 {
416 if (!is_user_logged_in()) {
417 return false;
418 }
419 return current_user_can('yatra_manage_settings');
420 }
421
422 /**
423 * The booking form config is needed to label booking data, so it is
424 * readable by booking staff, not only settings managers.
425 */
426 public function check_booking_form_permission(?WP_REST_Request $request = null): bool
427 {
428 if (!is_user_logged_in()) {
429 return false;
430 }
431 return current_user_can('yatra_manage_settings')
432 || current_user_can('yatra_view_bookings')
433 || current_user_can('yatra_edit_bookings');
434 }
435
436 /**
437 * GET /settings/booking-form[?trip_id=N]
438 *
439 * Without trip_id: the full config exactly as the Settings screen sees it.
440 * With trip_id: the config as that trip's checkout renders it — Pro form
441 * conditions resolved (no Pro / no conditions → identical to the global).
442 */
443 public function get_booking_form_config(WP_REST_Request $request)
444 {
445 try {
446 $trip_id = (int) $request->get_param('trip_id');
447
448 return $this->success_response([
449 'booking_form_config' => \Yatra\Services\SettingsService::getBookingFormConfig($trip_id > 0 ? $trip_id : null),
450 'trip_id' => $trip_id > 0 ? $trip_id : null,
451 ]);
452 } catch (\Exception $e) {
453 return $this->error_response($e->getMessage(), 500);
454 }
455 }
456
457 /**
458 * Get all settings
459 */
460 public function get_settings(WP_REST_Request $request)
461 {
462 try {
463 $settings = [];
464
465 // Get all settings from WordPress options table with yatra_ prefix.
466 // A sentinel default is essential here: get_option() returns boolean
467 // false for a stored-false option just as it does for a missing one,
468 // so checking `=== false` would reset every saved-off boolean back to
469 // its default. That is exactly the "Show sold-out dates" bug — the
470 // storefront honoured the saved value (isEnabled coerces '' -> false)
471 // while the admin checkbox re-appeared enabled because this endpoint
472 // handed React the default (true) instead of the saved false.
473 $unset_sentinel = "\0__yatra_option_unset__\0";
474 foreach ($this->default_settings as $key => $default_value) {
475 $option_name = 'yatra_' . $key;
476 $value = get_option($option_name, $unset_sentinel);
477
478 // Only use default when the option truly does not exist.
479 if ($value === $unset_sentinel) {
480 $value = $default_value;
481 }
482
483 // Auto-Confirm mode has no stored default — it is resolved on
484 // the fly. Return the effective mode so the admin shows the
485 // site's real behaviour: a stored choice if the operator made
486 // one, otherwise derived from the legacy boolean
487 // (true -> 'all', false -> 'online'). Prevents an existing
488 // "confirm all" site from displaying (and re-saving) as 'online'.
489 if ($key === 'auto_confirm_mode' && function_exists('yatra_get_auto_confirm_mode')) {
490 $value = yatra_get_auto_confirm_mode();
491 }
492
493 // Stored empty string should behave like "unset" for delivery identity (matches installer / backfill).
494 if (($key === 'admin_email' || $key === 'from_email') && is_string($value) && trim($value) === '') {
495 $wp = (string) get_option('admin_email', '');
496 $value = $wp !== '' ? $wp : $value;
497 }
498 if ($key === 'from_name' && is_string($value) && trim($value) === '') {
499 $wp = (string) get_bloginfo('name');
500 $value = $wp !== '' ? $wp : $value;
501 }
502
503 // Handle serialized arrays (for fields like payment_gateways, customer_fields, etc.)
504 if (is_string($value) && is_serialized($value)) {
505 $value = maybe_unserialize($value);
506 }
507
508 // Ensure arrays are returned as arrays (not objects)
509 if (is_array($default_value) && !is_array($value)) {
510 $value = [];
511 }
512
513 // Boolean settings must round-trip to the admin as real booleans.
514 // update_option() stores false as '' and the object cache can
515 // return boolean false, so without this a disabled toggle would
516 // reach React as '' / false and the checkbox (checked unless the
517 // value is strictly !== false) would render enabled again.
518 if (is_bool($default_value)) {
519 $value = filter_var($value, FILTER_VALIDATE_BOOLEAN);
520 }
521
522 $settings[$key] = $value;
523 }
524
525 // Special handling for booking_form_config - always use getBookingFormConfig which handles locked fields
526 $settings['booking_form_config'] = \Yatra\Services\SettingsService::getBookingFormConfig();
527
528 // Merge in flexible payment settings from Pro module if enabled
529 $flexible_payment_settings = apply_filters('yatra_get_flexible_payment_settings', []);
530 if (!empty($flexible_payment_settings)) {
531 $settings = array_merge($settings, $flexible_payment_settings);
532 }
533
534 $scheduled_payment_settings = apply_filters('yatra_get_scheduled_payment_settings', []);
535 if (!empty($scheduled_payment_settings)) {
536 $settings = array_merge($settings, $scheduled_payment_settings);
537 }
538
539 // Scheduled payment keys are owned by Pro (yatra_pro_scheduled_payments), not yatra_* options.
540 foreach (
541 [
542 'enable_scheduled_payments',
543 'scheduled_payment_type',
544 'scheduled_payment_days',
545 'scheduled_payment_installments',
546 'scheduled_payment_interval',
547 'scheduled_payment_reminder_days',
548 'balance_anchor',
549 'balance_due_days',
550 ] as $sk
551 ) {
552 if (array_key_exists($sk, $this->default_settings)) {
553 $settings[$sk] = \Yatra\Services\SettingsService::get(
554 $sk,
555 $this->default_settings[$sk]
556 );
557 }
558 }
559
560 $settings = $this->syncAccountRouteSettingsForResponse($settings);
561
562 /**
563 * Allow Pro modules to align REST payloads with canonical option stores
564 * (e.g. GA4 settings that also live in yatra_google_analytics_settings).
565 */
566 $settings = apply_filters('yatra_rest_settings', $settings);
567
568 return $this->success_response($settings);
569 } catch (\Exception $e) {
570 return $this->error_response($e->getMessage(), 500);
571 }
572 }
573
574 /**
575 * Update settings
576 */
577 public function update_settings(WP_REST_Request $request)
578 {
579 try {
580 $data = $request->get_json_params();
581
582 if (!is_array($data)) {
583 return $this->error_response('Invalid settings data', 400);
584 }
585
586 $updated = [];
587 $errors = [];
588
589 // Check if Dynamic Form Field module is enabled
590 $is_dynamic_form_enabled = apply_filters('yatra_dynamic_form_field_enabled', false);
591
592 // Check if Flexible Payments module is enabled (Pro feature)
593 $is_flexible_payments_enabled = apply_filters('yatra_flexible_payments_enabled', false);
594
595 $is_scheduled_payments_module = apply_filters('yatra_scheduled_payments_module_active', false);
596
597 // Flexible payment settings keys (Pro only)
598 $flexible_payment_keys = [
599 'deposit_required', 'deposit_percentage', 'partial_payment',
600 'partial_payment_percentage', 'enable_deposit', 'allow_save_payment_methods',
601 ];
602
603 $scheduled_payment_keys = [
604 'enable_scheduled_payments',
605 'scheduled_payment_type',
606 'scheduled_payment_days',
607 'scheduled_payment_installments',
608 'scheduled_payment_interval',
609 'scheduled_payment_reminder_days',
610 'balance_anchor',
611 'balance_due_days',
612 ];
613
614 // Collect flexible payment settings to delegate to Pro
615 $flexible_payment_settings = [];
616
617 $scheduled_payment_settings_batch = [];
618
619 // Process each setting
620 foreach ($data as $key => $value) {
621 // Skip booking_form_config if Dynamic Form Field module is not enabled
622 // This allows the settings to save without error when the module is disabled
623 if ($key === 'booking_form_config' && !$is_dynamic_form_enabled) {
624 continue;
625 }
626
627 // Delegate flexible payment settings to Pro module
628 if (in_array($key, $flexible_payment_keys, true)) {
629 if ($is_flexible_payments_enabled) {
630 $flexible_payment_settings[$key] = $value;
631 }
632 // Skip saving in Free plugin - Pro handles these
633 continue;
634 }
635
636 if (in_array($key, $scheduled_payment_keys, true)) {
637 if ($is_scheduled_payments_module) {
638 $scheduled_payment_settings_batch[$key] = $value;
639 }
640 continue;
641 }
642
643 // Wishlist toggle: only meaningful with Yatra Pro active
644 if ($key === 'enable_wishlist' && !apply_filters('yatra_is_pro_active', false)) {
645 continue;
646 }
647
648 // Validate that the key exists in default settings
649 if (!array_key_exists($key, $this->default_settings)) {
650 $errors[] = sprintf('Unknown setting: %s', $key);
651 continue;
652 }
653
654 // Sanitize and validate the value based on its type
655 $sanitized_value = $this->sanitize_setting($key, $value);
656
657 if ($sanitized_value === null) {
658 $errors[] = sprintf('Invalid value for setting: %s', $key);
659 continue;
660 }
661
662 // Save to WordPress options table with yatra_ prefix
663 $option_name = 'yatra_' . $key;
664
665 // Serialize arrays for storage
666 if (is_array($sanitized_value)) {
667 $sanitized_value = maybe_serialize($sanitized_value);
668 }
669
670 $result = update_option($option_name, $sanitized_value);
671
672 if ($result !== false) {
673 $updated[] = $key;
674 }
675 }
676
677 // Delegate flexible payment settings to Pro module for saving
678 if (!empty($flexible_payment_settings) && $is_flexible_payments_enabled) {
679 do_action('yatra_save_flexible_payment_settings', $flexible_payment_settings);
680 $updated = array_merge($updated, array_keys($flexible_payment_settings));
681 }
682
683 if (!empty($scheduled_payment_settings_batch) && $is_scheduled_payments_module) {
684 do_action('yatra_save_scheduled_payment_settings', $scheduled_payment_settings_batch);
685 $updated = array_merge($updated, array_keys($scheduled_payment_settings_batch));
686 }
687
688 // Sync currency keys: keep 'currency' and 'default_currency' in sync
689 // Admin UI has both Payment Settings (currency) and Currency Settings (default_currency)
690 if (in_array('default_currency', $updated, true) && !in_array('currency', $updated, true)) {
691 $sync_currency = get_option('yatra_default_currency', 'USD');
692 update_option('yatra_currency', $sync_currency);
693 } elseif (in_array('currency', $updated, true) && !in_array('default_currency', $updated, true)) {
694 $sync_currency = get_option('yatra_currency', 'USD');
695 update_option('yatra_default_currency', $sync_currency);
696 }
697
698 if (in_array('customer_account_page', $updated, true)) {
699 $this->persistAccountBaseFromCustomerAccountPage();
700 }
701
702 if (!empty($errors)) {
703 $errorSummary = implode('; ', $errors);
704 return $this->error_response(
705 sprintf('Some settings could not be updated: %s', $errorSummary),
706 400,
707 [
708 'errors' => $errors,
709 'updated' => $updated,
710 ]
711 );
712 }
713
714 // Flush rewrite rules if permalink settings were updated
715 if (in_array('trip_base', $updated, true) ||
716 in_array('destination_base', $updated, true) ||
717 in_array('activity_base', $updated, true) ||
718 in_array('trip_category_base', $updated, true) ||
719 in_array('booking_base', $updated, true) ||
720 in_array('use_booking_page', $updated, true) ||
721 in_array('booking_page_id', $updated, true) ||
722 in_array('customer_account_page', $updated, true)) {
723 // Use hard flush to ensure rules are saved to database
724 flush_rewrite_rules(true);
725 }
726
727 if (!empty($updated)) {
728 \Yatra\Services\SettingsService::reload();
729 }
730
731 // Cross-validation: booking-auth settings interact via OR
732 // logic in booking-content.php, so some combinations are
733 // semantically inconsistent or redundant. We don't block
734 // the save (the resulting state still has well-defined
735 // behavior), but we surface a clear notice so the operator
736 // understands what they just configured.
737 //
738 // require_login=true + allow_guest_checkout=true →
739 // require_login wins; allow_guest_checkout is a no-op.
740 // require_login=true + allow_guest_checkout=false →
741 // Strictest setting (login required, no guest path).
742 // Internally consistent.
743 // require_login=false + allow_guest_checkout=false →
744 // Guests blocked, logged-in users can book. Consistent.
745 // require_login=false + allow_guest_checkout=true →
746 // Default. Permissive.
747 $notices = [];
748 $effective_require_login = \array_key_exists('require_login', $data)
749 ? (bool) $data['require_login']
750 : (bool) \Yatra\Services\SettingsService::get('require_login', false);
751 $effective_allow_guest = \array_key_exists('allow_guest_checkout', $data)
752 ? (bool) $data['allow_guest_checkout']
753 : (bool) \Yatra\Services\SettingsService::get('allow_guest_checkout', true);
754
755 if ($effective_require_login && $effective_allow_guest) {
756 $notices[] = [
757 'level' => 'warning',
758 'code' => 'booking_auth_redundant',
759 'message' => __(
760 '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.',
761 'yatra'
762 ),
763 ];
764 }
765
766 // Scheduled Payments + guest checkout — incompatible at
767 // the gateway level. Scheduled charges require a saved
768 // payment-method tied to a customer record on the
769 // gateway side (Stripe Customer, etc.), which in turn
770 // requires a logged-in WP user. When both settings are
771 // on, the system gracefully skips installment creation
772 // for guest bookings — but operators expect them to
773 // work and only discover the gap when reconciling
774 // unpaid bookings weeks later. Surface this proactively.
775 $effective_scheduled_payments = \array_key_exists('enable_scheduled_payments', $data)
776 ? (bool) $data['enable_scheduled_payments']
777 : (bool) \Yatra\Services\SettingsService::get('enable_scheduled_payments', false);
778 if (
779 $effective_scheduled_payments
780 && $effective_allow_guest
781 && !$effective_require_login
782 ) {
783 $notices[] = [
784 'level' => 'info',
785 'code' => 'scheduled_payments_guest_caveat',
786 'message' => __(
787 '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.',
788 'yatra'
789 ),
790 ];
791 }
792
793 $response = [
794 'message' => 'Settings updated successfully',
795 'updated' => $updated,
796 ];
797 if ($notices !== []) {
798 $response['notices'] = $notices;
799 }
800 return $this->success_response($response);
801 } catch (\Exception $e) {
802 return $this->error_response($e->getMessage(), 500);
803 }
804 }
805
806 /**
807 * Sanitize and validate setting value
808 * Pro plugins can handle sanitization of their own settings via filter
809 *
810 * @param mixed $value
811 * @return mixed
812 */
813 private function sanitize_setting(string $key, $value)
814 {
815 $default = $this->default_settings[$key] ?? null;
816 $default_type = gettype($default);
817
818 // Allow Pro plugins to handle sanitization of their own settings
819 $filtered_value = apply_filters('yatra_sanitize_setting', null, $key, $value, $default);
820 if ($filtered_value !== null) {
821 return $filtered_value;
822 }
823
824 // The booking-form config has its own structured sanitiser (field type
825 // and width whitelists, locked core fields, text-block content, per-trip
826 // conditions). It must run BEFORE the generic
827 // is_array($default) branch below: that branch only text-sanitises
828 // values and was catching this key first — because its default is [] —
829 // so the structured sanitiser further down was never reached and any
830 // shape at all was stored.
831 if ($key === 'booking_form_config') {
832 return is_array($value) ? $this->sanitize_booking_form_config($value) : [];
833 }
834
835 // Handle null values - use default
836 if ($value === null) {
837 return $default;
838 }
839
840 // Handle arrays
841 if (is_array($default)) {
842 if (!is_array($value)) {
843 return null;
844 }
845 // Sanitize array values
846 return array_map(function($item) {
847 if (is_string($item)) {
848 return sanitize_text_field($item);
849 }
850 if (is_numeric($item)) {
851 return is_float($item) ? (float) $item : (int) $item;
852 }
853 if (is_bool($item)) {
854 return (bool) $item;
855 }
856 if (is_array($item)) {
857 return $this->sanitize_array($item);
858 }
859 return $item;
860 }, $value);
861 }
862
863 // Handle booleans (REST may send true/false strings)
864 if (is_bool($default)) {
865 if (is_bool($value)) {
866 return $value;
867 }
868 if (is_string($value)) {
869 $parsed = filter_var($value, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE);
870 return $parsed !== null ? $parsed : (bool) $value;
871 }
872 return (bool) $value;
873 }
874
875 // Handle integers
876 if (is_int($default)) {
877 if (!is_numeric($value)) {
878 return null;
879 }
880 $int_value = (int) $value;
881 // Validate ranges for specific fields
882 if ($key === 'booking_expiry_hours' && $int_value < 0) {
883 return null;
884 }
885 // Storefront booking horizon: 1–36 months. Out of range is rejected
886 // (not clamped) so a bad write can never blank the calendar — the
887 // previously stored value, or the 12-month default, stays in force.
888 if ($key === 'availability_horizon_months' && ($int_value < 1 || $int_value > 36)) {
889 return null;
890 }
891 if ($key === 'partial_payment_percentage' && ($int_value < 0 || $int_value > 100)) {
892 return null;
893 }
894 if ($key === 'deposit_percentage' && ($int_value < 0 || $int_value > 100)) {
895 return null;
896 }
897 if ($key === 'tax_rate' && ($int_value < 0 || $int_value > 100)) {
898 return null;
899 }
900 if ($key === 'smtp_port' && ($int_value < 1 || $int_value > 65535)) {
901 return null;
902 }
903 return $int_value;
904 }
905
906 // Handle floats
907 if (is_float($default)) {
908 if (!is_numeric($value)) {
909 return null;
910 }
911 $float_value = (float) $value;
912 if ($float_value < 0) {
913 return null;
914 }
915 return $float_value;
916 }
917
918 // Handle strings
919 if (is_string($default)) {
920 if ($key === 'timezone') {
921 $tz = is_string($value) ? trim($value) : '';
922 if ($tz === '') {
923 return is_string($default) ? $default : 'UTC';
924 }
925 try {
926 new \DateTimeZone($tz);
927
928 return $tz;
929 } catch (\Exception $e) {
930 return is_string($default) ? $default : 'UTC';
931 }
932 }
933 if ($key === 'currency_position') {
934 $allowed = ['left', 'right', 'left_space', 'right_space', 'before', 'after'];
935 $v = is_string($value) ? strtolower(trim($value)) : '';
936
937 return in_array($v, $allowed, true) ? $v : (is_string($default) ? $default : 'left');
938 }
939 if ($key === 'discount_stacking_mode') {
940 // Strict enum — any other value silently falls back to the
941 // backward-compatible default so a malformed POST cannot
942 // change pricing behavior unexpectedly.
943 $allowed = ['both', 'discount_only', 'dynamic_pricing_only', 'best_for_customer'];
944 $v = is_string($value) ? strtolower(trim($value)) : '';
945
946 return in_array($v, $allowed, true) ? $v : 'both';
947 }
948 // Special handling for specific fields
949 if ($key === 'company_email' || $key === 'admin_email' || $key === 'from_email' || $key === 'smtp_username') {
950 return sanitize_email($value);
951 }
952 if ($key === 'company_website' || $key === 'company_logo' || $key === 'google_analytics' || $key === 'facebook_pixel') {
953 return esc_url_raw($value);
954 }
955 if ($key === 'seo_trip_meta_title') {
956 // Allow more characters for meta title, but strip HTML
957 return wp_strip_all_tags($value);
958 }
959 if ($key === 'seo_trip_meta_description') {
960 // Allow more characters for meta description, but strip HTML
961 return wp_strip_all_tags($value);
962 }
963 if ($key === 'seo_trip_meta_keywords') {
964 // Allow keywords, strip HTML and sanitize
965 return sanitize_text_field($value);
966 }
967 if ($key === 'frontend_primary_color') {
968 return \Yatra\Utils\FrontendThemeCss::sanitizePrimaryColor(is_string($value) ? $value : '');
969 }
970 if ($key === 'frontend_container_max_width') {
971 return \Yatra\Utils\FrontendThemeCss::sanitizeContainerMaxWidthSetting(
972 is_string($value) ? $value : ''
973 );
974 }
975 if ($key === 'frontend_listing_card_layout') {
976 $allowed = ['standard', 'compact_mobile', 'compact_all'];
977 $v = is_string($value) ? strtolower(trim($value)) : '';
978 return in_array($v, $allowed, true) ? $v : 'standard';
979 }
980 if ($key === 'auto_confirm_mode') {
981 $allowed = ['none', 'online', 'all'];
982 $v = is_string($value) ? strtolower(trim($value)) : '';
983 return in_array($v, $allowed, true) ? $v : 'online';
984 }
985 if (is_string($key) && strpos($key, 'email_tpl_') === 0 && substr($key, -5) === '_body') {
986 return wp_kses_post((string) $value);
987 }
988 if (is_string($key) && strpos($key, 'email_tpl_') === 0 && substr($key, -8) === '_subject') {
989 return sanitize_text_field((string) $value);
990 }
991 if ($key === 'smtp_password' || $key === 'api_key' || $key === 'sms_api_key' || $key === 'recaptcha_secret_key') {
992 // Don't sanitize passwords/keys too aggressively
993 return sanitize_text_field($value);
994 }
995 if ($key === 'gateway_configs') {
996 // Handle nested array structure for gateway configs
997 if (is_array($value)) {
998 return $this->sanitize_gateway_configs($value);
999 }
1000 return [];
1001 }
1002 if ($key === 'tax_rates') {
1003 // Handle nested array structure for tax rates
1004 if (is_array($value)) {
1005 return $this->sanitize_tax_rates($value);
1006 }
1007 return [];
1008 }
1009 return sanitize_text_field($value);
1010 }
1011
1012 return $value;
1013 }
1014
1015 /**
1016 * Sanitize nested array
1017 */
1018 private function sanitize_array(array $array): array
1019 {
1020 $sanitized = [];
1021 foreach ($array as $k => $v) {
1022 $sanitized_key = is_string($k) ? sanitize_key($k) : $k;
1023 if (is_array($v)) {
1024 $sanitized[$sanitized_key] = $this->sanitize_array($v);
1025 } elseif (is_string($v)) {
1026 $sanitized[$sanitized_key] = sanitize_text_field($v);
1027 } elseif (is_numeric($v)) {
1028 $sanitized[$sanitized_key] = is_float($v) ? (float) $v : (int) $v;
1029 } elseif (is_bool($v)) {
1030 $sanitized[$sanitized_key] = (bool) $v;
1031 } else {
1032 $sanitized[$sanitized_key] = $v;
1033 }
1034 }
1035 return $sanitized;
1036 }
1037
1038 /**
1039 * Sanitize gateway configs
1040 */
1041 private function sanitize_gateway_configs(array $configs): array
1042 {
1043 $sanitized = [];
1044 foreach ($configs as $gateway => $config) {
1045 if (!is_array($config)) {
1046 continue;
1047 }
1048 $sanitized_gateway = sanitize_key($gateway);
1049 $row = [
1050 'enabled' => isset($config['enabled']) ? (bool) $config['enabled'] : false,
1051 'icon' => isset($config['icon']) ? esc_url_raw($config['icon']) : '',
1052 'title' => isset($config['title']) ? sanitize_text_field($config['title']) : '',
1053 'description' => isset($config['description']) ? sanitize_textarea_field($config['description']) : '',
1054 'api_key' => isset($config['api_key']) ? sanitize_text_field($config['api_key']) : '',
1055 'api_secret' => isset($config['api_secret']) ? sanitize_text_field($config['api_secret']) : '',
1056 'client_id' => isset($config['client_id']) ? sanitize_text_field($config['client_id']) : '',
1057 'client_secret' => isset($config['client_secret']) ? sanitize_text_field($config['client_secret']) : '',
1058 'merchant_id' => isset($config['merchant_id']) ? sanitize_text_field($config['merchant_id']) : '',
1059 'public_key' => isset($config['public_key']) ? sanitize_text_field($config['public_key']) : '',
1060 'private_key' => isset($config['private_key']) ? sanitize_text_field($config['private_key']) : '',
1061 'webhook_secret' => isset($config['webhook_secret']) ? sanitize_text_field($config['webhook_secret']) : '',
1062 'test_mode' => isset($config['test_mode']) ? (bool) $config['test_mode'] : false,
1063 'sandbox' => isset($config['sandbox']) ? (bool) $config['sandbox'] : false,
1064 ];
1065
1066 if ($sanitized_gateway === 'paypal') {
1067 $mode = isset($config['mode']) && in_array((string) $config['mode'], ['simple', 'advanced'], true)
1068 ? (string) $config['mode']
1069 : 'simple';
1070 $row['email'] = isset($config['email']) ? sanitize_email((string) $config['email']) : '';
1071 $row['mode'] = $mode;
1072 }
1073
1074 if ($sanitized_gateway === 'pay_later') {
1075 $row['payment_deadline_days'] = isset($config['payment_deadline_days'])
1076 ? max(1, min(60, (int) $config['payment_deadline_days']))
1077 : 7;
1078 $row['auto_cancel_days'] = isset($config['auto_cancel_days'])
1079 ? max(0, min(30, (int) $config['auto_cancel_days']))
1080 : 3;
1081 $row['require_deposit'] = isset($config['require_deposit']) ? (bool) $config['require_deposit'] : false;
1082 $row['deposit_amount'] = isset($config['deposit_amount'])
1083 ? max(1, min(50, (int) $config['deposit_amount']))
1084 : 10;
1085 $row['reminder_days'] = isset($config['reminder_days'])
1086 ? sanitize_text_field((string) $config['reminder_days'])
1087 : '7,3,1';
1088 }
1089
1090 if ($sanitized_gateway === 'stripe') {
1091 $allowedStripeMethods = ['card', 'google_pay', 'apple_pay'];
1092 $methodsRaw = isset($config['enabled_methods']) ? (string) $config['enabled_methods'] : '';
1093 if ($methodsRaw !== '') {
1094 $parts = array_filter(array_map('trim', explode(',', $methodsRaw)));
1095 $normalized = [];
1096 foreach ($parts as $part) {
1097 $slug = strtolower($part);
1098 if (in_array($slug, $allowedStripeMethods, true)) {
1099 $normalized[] = $slug;
1100 }
1101 }
1102 $row['enabled_methods'] = $normalized !== [] ? implode(',', $normalized) : 'card,google_pay,apple_pay';
1103 } else {
1104 $row['enabled_methods'] = 'card,google_pay,apple_pay';
1105 }
1106 foreach (['live_publishable_key', 'live_secret_key', 'test_publishable_key', 'test_secret_key'] as $stripeKey) {
1107 if (array_key_exists($stripeKey, $config)) {
1108 $row[$stripeKey] = sanitize_text_field((string) $config[$stripeKey]);
1109 }
1110 }
1111 }
1112
1113 if ($sanitized_gateway === 'razorpay') {
1114 $row['key_id'] = isset($config['key_id']) ? sanitize_text_field((string) $config['key_id']) : '';
1115 $row['key_secret'] = isset($config['key_secret']) ? sanitize_text_field((string) $config['key_secret']) : '';
1116 }
1117
1118 if ($sanitized_gateway === 'mollie') {
1119 $row['api_key'] = isset($config['api_key']) ? sanitize_text_field((string) $config['api_key']) : '';
1120 $row['webhook_url'] = isset($config['webhook_url']) ? esc_url_raw((string) $config['webhook_url']) : '';
1121 $allowedMollie = ['creditcard', 'ideal', 'bancontact', 'sofort', 'eps', 'giropay', 'paypal', 'sepadirectdebit'];
1122 $row['payment_methods'] = $this->sanitizeGatewayStringList(
1123 $config['payment_methods'] ?? [],
1124 $allowedMollie,
1125 ['creditcard', 'ideal', 'paypal']
1126 );
1127 }
1128
1129 if ($sanitized_gateway === 'paystack') {
1130 $row['public_key'] = isset($config['public_key']) ? sanitize_text_field((string) $config['public_key']) : '';
1131 $row['secret_key'] = isset($config['secret_key']) ? sanitize_text_field((string) $config['secret_key']) : '';
1132 $row['webhook_url'] = isset($config['webhook_url']) ? esc_url_raw((string) $config['webhook_url']) : '';
1133 $allowedPaystack = ['card', 'bank', 'ussd', 'qr', 'mobile_money', 'bank_transfer'];
1134 $row['payment_channels'] = $this->sanitizeGatewayStringList(
1135 $config['payment_channels'] ?? [],
1136 $allowedPaystack,
1137 ['card', 'bank', 'ussd']
1138 );
1139 unset($row['private_key']);
1140 }
1141
1142 if ($sanitized_gateway === 'square') {
1143 $row['application_id'] = isset($config['application_id']) ? sanitize_text_field((string) $config['application_id']) : '';
1144 $row['access_token'] = isset($config['access_token']) ? sanitize_text_field((string) $config['access_token']) : '';
1145 $row['location_id'] = isset($config['location_id']) ? sanitize_text_field((string) $config['location_id']) : '';
1146 }
1147
1148 if ($sanitized_gateway === 'authorize_net') {
1149 $row['api_login_id'] = isset($config['api_login_id']) ? sanitize_text_field((string) $config['api_login_id']) : '';
1150 $row['transaction_key'] = isset($config['transaction_key']) ? sanitize_text_field((string) $config['transaction_key']) : '';
1151 $row['public_client_key'] = isset($config['public_client_key']) ? sanitize_text_field((string) $config['public_client_key']) : '';
1152 }
1153
1154 if ($sanitized_gateway === 'bank_transfer') {
1155 $row['bank_name'] = isset($config['bank_name']) ? sanitize_text_field((string) $config['bank_name']) : '';
1156 $row['account_name'] = isset($config['account_name']) ? sanitize_text_field((string) $config['account_name']) : '';
1157 $row['account_number'] = isset($config['account_number']) ? sanitize_text_field((string) $config['account_number']) : '';
1158 $row['routing_code'] = isset($config['routing_code']) ? sanitize_text_field((string) $config['routing_code']) : '';
1159 $row['instructions'] = isset($config['instructions']) ? sanitize_textarea_field((string) $config['instructions']) : '';
1160 }
1161
1162 /**
1163 * Allow Pro add-ons or custom code to append keys after core sanitization.
1164 *
1165 * @param array<string, mixed> $row
1166 * @param array<string, mixed> $config
1167 * @return array<string, mixed>
1168 */
1169 $row = apply_filters('yatra_sanitize_gateway_config_row', $row, $sanitized_gateway, $config);
1170
1171 $sanitized[$sanitized_gateway] = $row;
1172 }
1173 return $sanitized;
1174 }
1175
1176 /**
1177 * Normalize multiselect gateway options (Mollie methods, Paystack channels, etc.).
1178 *
1179 * @param mixed $input
1180 * @param array<int, string> $allowed
1181 * @param array<int, string> $default
1182 * @return array<int, string>
1183 */
1184 private function sanitizeGatewayStringList($input, array $allowed, array $default): array
1185 {
1186 if (is_string($input) && $input !== '') {
1187 $input = array_map('trim', explode(',', $input));
1188 }
1189 if (!is_array($input)) {
1190 return $default;
1191 }
1192 $out = [];
1193 foreach ($input as $v) {
1194 $slug = sanitize_key((string) $v);
1195 if ($slug !== '' && in_array($slug, $allowed, true)) {
1196 $out[] = $slug;
1197 }
1198 }
1199 $out = array_values(array_unique($out));
1200
1201 return $out !== [] ? $out : $default;
1202 }
1203
1204 /**
1205 * Sanitize tax rates
1206 */
1207 private function sanitize_tax_rates(array $rates): array
1208 {
1209 $sanitized = [];
1210 foreach ($rates as $country => $rate) {
1211 $sanitized_country = sanitize_text_field($country);
1212 if (is_numeric($rate)) {
1213 $float_rate = (float) $rate;
1214 if ($float_rate >= 0 && $float_rate <= 100) {
1215 $sanitized[$sanitized_country] = $float_rate;
1216 }
1217 }
1218 }
1219 return $sanitized;
1220 }
1221
1222 /**
1223 * Sanitize booking form configuration
1224 */
1225 private function sanitize_booking_form_config(array $config): array
1226 {
1227 $sanitized = [];
1228 $allowed_form_types = ['contact_form', 'emergency_contact_form', 'traveler_form'];
1229
1230 foreach ($config as $form_type => $form_config) {
1231 if (!in_array($form_type, $allowed_form_types, true)) {
1232 continue;
1233 }
1234
1235 $sanitized[$form_type] = [
1236 'title' => isset($form_config['title']) ? sanitize_text_field($form_config['title']) : '',
1237 'description' => isset($form_config['description']) ? sanitize_text_field($form_config['description']) : '',
1238 'enabled' => isset($form_config['enabled']) ? (bool) $form_config['enabled'] : true,
1239 'fields' => $this->sanitize_booking_form_fields($form_config['fields'] ?? null, $form_type),
1240 ];
1241
1242 // Per-trip form conditions (Pro Dynamic Form Field): each condition
1243 // is a complete alternative version of this section — its own
1244 // title, description and field list — used on the trips it names.
1245 // Only persisted when there is at least one, so configs saved
1246 // without the feature stay byte-identical.
1247 $conditions = $this->sanitize_booking_form_conditions($form_config['conditions'] ?? null, $form_type);
1248 if ($conditions !== []) {
1249 $sanitized[$form_type]['conditions'] = $conditions;
1250 }
1251 }
1252
1253 return apply_filters('yatra_save_booking_form_config', $sanitized, $config);
1254 }
1255
1256 /**
1257 * Sanitise one section's field list (global fields or a condition's fields).
1258 *
1259 * @param mixed $fields
1260 * @return array<int, array<string, mixed>>
1261 */
1262 private function sanitize_booking_form_fields($fields, string $form_type): array
1263 {
1264 $allowed_field_types = ['text', 'email', 'tel', 'date', 'select', 'country', 'textarea', 'checkbox', 'number', 'text_block'];
1265 $allowed_widths = ['full', 'half', 'third'];
1266 $sanitized = [];
1267
1268 if (empty($fields) || !is_array($fields)) {
1269 return $sanitized;
1270 }
1271
1272 foreach ($fields as $field) {
1273 if (!is_array($field) || empty($field['id'])) {
1274 continue;
1275 }
1276
1277 $sanitized_field = [
1278 'id' => sanitize_key($field['id']),
1279 'type' => in_array($field['type'] ?? 'text', $allowed_field_types, true) ? $field['type'] : 'text',
1280 'label' => isset($field['label']) ? sanitize_text_field($field['label']) : '',
1281 'placeholder' => isset($field['placeholder']) ? sanitize_text_field($field['placeholder']) : '',
1282 'required' => isset($field['required']) ? (bool) $field['required'] : false,
1283 'enabled' => isset($field['enabled']) ? (bool) $field['enabled'] : true,
1284 'order' => isset($field['order']) ? (int) $field['order'] : 0,
1285 'width' => in_array($field['width'] ?? 'full', $allowed_widths, true) ? ($field['width'] ?? 'full') : 'full',
1286 ];
1287
1288 // Only persist `locked` when set: every reader treats a missing key
1289 // as unlocked, and configs saved before this sanitiser ran never
1290 // carried a `locked => false`, so they stay byte-identical.
1291 if (!empty($field['locked'])) {
1292 $sanitized_field['locked'] = true;
1293 }
1294
1295 // Handle optional section
1296 if (!empty($field['section'])) {
1297 $sanitized_field['section'] = sanitize_key($field['section']);
1298 }
1299
1300 // Per-traveler targeting — Traveler section only. Whitelist
1301 // the allowed values; only persist the non-default "lead" so
1302 // other sections and existing configs stay byte-identical.
1303 if (
1304 $form_type === 'traveler_form'
1305 && ($field['applies_to'] ?? 'all') === 'lead'
1306 ) {
1307 $sanitized_field['applies_to'] = 'lead';
1308 }
1309
1310 // Handle options for select fields
1311 if ($sanitized_field['type'] === 'select' && !empty($field['options']) && is_array($field['options'])) {
1312 $sanitized_field['options'] = [];
1313 foreach ($field['options'] as $option) {
1314 if (is_array($option) && isset($option['value'])) {
1315 $sanitized_field['options'][] = [
1316 'value' => sanitize_key($option['value']),
1317 'label' => isset($option['label']) ? sanitize_text_field($option['label']) : $option['value'],
1318 ];
1319 }
1320 }
1321 }
1322
1323 // A text block is display-only content placed between fields:
1324 // keep its (safe-HTML) content, and it can never be required.
1325 if ($sanitized_field['type'] === 'text_block') {
1326 $sanitized_field['content'] = isset($field['content']) ? wp_kses_post($field['content']) : '';
1327 $sanitized_field['required'] = false;
1328 }
1329
1330 // Phone fields: the country-code selector is ON by default.
1331 // Only persist the non-default `false`, so existing configs
1332 // (which never carried this key) stay byte-identical and read
1333 // back as ON.
1334 if (
1335 $sanitized_field['type'] === 'tel'
1336 && array_key_exists('show_country_code', $field)
1337 && !$field['show_country_code']
1338 ) {
1339 $sanitized_field['show_country_code'] = false;
1340 }
1341
1342 $sanitized[] = $sanitized_field;
1343 }
1344
1345 // Sort fields by order
1346 usort($sanitized, function ($a, $b) {
1347 return ($a['order'] ?? 0) - ($b['order'] ?? 0);
1348 });
1349
1350 return $sanitized;
1351 }
1352
1353 /**
1354 * Sanitise a section's per-trip conditions. A condition without any
1355 * target (trip, category or trip type) can never match and is dropped.
1356 *
1357 * @param mixed $conditions
1358 * @return array<int, array<string, mixed>>
1359 */
1360 private function sanitize_booking_form_conditions($conditions, string $form_type): array
1361 {
1362 if (empty($conditions) || !is_array($conditions)) {
1363 return [];
1364 }
1365
1366 $allowed_trip_types = ['single_day', 'multi_day', 'flexible'];
1367 $sanitized = [];
1368 $n = 0;
1369
1370 foreach ($conditions as $condition) {
1371 if (!is_array($condition)) {
1372 continue;
1373 }
1374 $n++;
1375
1376 $raw_targets = is_array($condition['targets'] ?? null) ? $condition['targets'] : [];
1377 $targets = [];
1378 foreach (['trips', 'categories'] as $selector) {
1379 $ids = array_values(array_unique(array_filter(
1380 array_map('intval', is_array($raw_targets[$selector] ?? null) ? $raw_targets[$selector] : []),
1381 static function ($id) {
1382 return $id > 0;
1383 }
1384 )));
1385 if ($ids !== []) {
1386 $targets[$selector] = $ids;
1387 }
1388 }
1389 $types = array_values(array_unique(array_filter(
1390 array_map(static function ($t) {
1391 return sanitize_key((string) $t);
1392 }, is_array($raw_targets['trip_types'] ?? null) ? $raw_targets['trip_types'] : []),
1393 static function ($t) use ($allowed_trip_types) {
1394 return in_array($t, $allowed_trip_types, true);
1395 }
1396 )));
1397 if ($types !== []) {
1398 $targets['trip_types'] = $types;
1399 }
1400 if ($targets === []) {
1401 continue;
1402 }
1403
1404 $id = sanitize_key((string) ($condition['id'] ?? ''));
1405 $sanitized[] = [
1406 'id' => $id !== '' ? $id : 'condition_' . $n,
1407 'targets' => $targets,
1408 'title' => isset($condition['title']) ? sanitize_text_field($condition['title']) : '',
1409 'description' => isset($condition['description']) ? sanitize_text_field($condition['description']) : '',
1410 'fields' => $this->sanitize_booking_form_fields($condition['fields'] ?? null, $form_type),
1411 ];
1412 }
1413
1414 return $sanitized;
1415 }
1416
1417 /**
1418 * Flush rewrite rules
1419 */
1420 public function flush_rewrite_rules(WP_REST_Request $request)
1421 {
1422 try {
1423 // Flush rewrite rules
1424 flush_rewrite_rules(true);
1425
1426 return $this->success_response([
1427 'message' => 'Rewrite rules flushed successfully',
1428 ]);
1429 } catch (\Exception $e) {
1430 return $this->error_response($e->getMessage(), 500);
1431 }
1432 }
1433
1434 /**
1435 * Get list of WordPress pages for booking page selection
1436 * Note: We don't check for shortcode here - it's checked on-demand when user selects a page
1437 */
1438 public function get_pages(WP_REST_Request $request)
1439 {
1440 try {
1441 $pages = get_pages([
1442 'post_status' => 'publish',
1443 'sort_column' => 'post_title',
1444 'sort_order' => 'ASC',
1445 ]);
1446
1447 $page_list = [];
1448 foreach ($pages as $page) {
1449 $page_list[] = [
1450 'id' => $page->ID,
1451 'title' => $page->post_title,
1452 'slug' => $page->post_name,
1453 'url' => get_permalink($page->ID),
1454 ];
1455 }
1456
1457 return $this->success_response($page_list);
1458 } catch (\Exception $e) {
1459 return $this->error_response($e->getMessage(), 500);
1460 }
1461 }
1462
1463 /**
1464 * Check if a page has the booking shortcode
1465 */
1466 public function check_booking_shortcode(WP_REST_Request $request)
1467 {
1468 try {
1469 $page_id = (int) $request->get_param('page_id');
1470
1471 if ($page_id <= 0) {
1472 return $this->error_response('Invalid page ID', 400);
1473 }
1474
1475 $page = get_post($page_id);
1476
1477 if (!$page || $page->post_type !== 'page') {
1478 return $this->error_response('Page not found', 404);
1479 }
1480
1481 $has_shortcode = has_shortcode($page->post_content, 'yatra_booking');
1482
1483 return $this->success_response([
1484 'page_id' => $page_id,
1485 'has_shortcode' => $has_shortcode,
1486 'page_title' => $page->post_title,
1487 'page_url' => get_permalink($page_id),
1488 'edit_url' => get_edit_post_link($page_id, 'raw'),
1489 ]);
1490 } catch (\Exception $e) {
1491 return $this->error_response($e->getMessage(), 500);
1492 }
1493 }
1494
1495 /**
1496 * Insert booking shortcode into a page
1497 */
1498 public function insert_booking_shortcode(WP_REST_Request $request)
1499 {
1500 try {
1501 $page_id = (int) $request->get_param('page_id');
1502
1503 if ($page_id <= 0) {
1504 return $this->error_response('Invalid page ID', 400);
1505 }
1506
1507 $page = get_post($page_id);
1508
1509 if (!$page || $page->post_type !== 'page') {
1510 return $this->error_response('Page not found', 404);
1511 }
1512
1513 // Check if shortcode already exists
1514 if (has_shortcode($page->post_content, 'yatra_booking')) {
1515 return $this->success_response([
1516 'message' => 'Shortcode already exists on this page',
1517 'page_id' => $page_id,
1518 'already_exists' => true,
1519 ]);
1520 }
1521
1522 // Append shortcode to page content
1523 $new_content = $page->post_content . "\n\n[yatra_booking]";
1524
1525 $result = wp_update_post([
1526 'ID' => $page_id,
1527 'post_content' => $new_content,
1528 ], true);
1529
1530 if (is_wp_error($result)) {
1531 return $this->error_response($result->get_error_message(), 500);
1532 }
1533
1534 return $this->success_response([
1535 'message' => 'Shortcode added successfully',
1536 'page_id' => $page_id,
1537 'page_url' => get_permalink($page_id),
1538 ]);
1539 } catch (\Exception $e) {
1540 return $this->error_response($e->getMessage(), 500);
1541 }
1542 }
1543
1544 /**
1545 * Keep Settings → Customer "account page" path aligned with {@see RouteMatcher} / {@see Router} (yatra_account_base).
1546 *
1547 * @param array<string, mixed> $settings
1548 * @return array<string, mixed>
1549 */
1550 private function syncAccountRouteSettingsForResponse(array $settings): array
1551 {
1552 // Prefer the full saved path so admin "View" matches Settings → Customer (not only yatra_account_base slug).
1553 $savedPath = get_option('yatra_customer_account_page', '');
1554 if (is_string($savedPath) && $savedPath !== '' && $savedPath !== '0') {
1555 $normalized = '/' . trim(str_replace('\\', '/', $savedPath), '/');
1556 if ($normalized === '/') {
1557 $normalized = '/my-account';
1558 }
1559 $settings['customer_account_page'] = $normalized;
1560
1561 return $settings;
1562 }
1563
1564 $stored = get_option('yatra_account_base', '');
1565 if (is_string($stored) && $stored !== '') {
1566 $settings['customer_account_page'] = '/' . $stored;
1567
1568 return $settings;
1569 }
1570
1571 $cpp = (string) ($settings['customer_account_page'] ?? '');
1572 $slug = self::accountSlugFromCustomerAccountPath($cpp !== '' ? $cpp : '/account');
1573 update_option('yatra_account_base', $slug);
1574 $settings['customer_account_page'] = '/' . $slug;
1575
1576 return $settings;
1577 }
1578
1579 private function persistAccountBaseFromCustomerAccountPage(): void
1580 {
1581 $cpp = (string) get_option('yatra_customer_account_page', '');
1582 update_option('yatra_account_base', self::accountSlugFromCustomerAccountPath($cpp));
1583 }
1584
1585 private static function accountSlugFromCustomerAccountPath(string $path): string
1586 {
1587 $path = trim(str_replace('\\', '/', $path), '/');
1588 $parts = array_values(array_filter(explode('/', $path), static fn ($p) => $p !== ''));
1589 $segment = $parts !== [] ? end($parts) : 'account';
1590 $slug = sanitize_title($segment);
1591
1592 return $slug !== '' ? $slug : 'account';
1593 }
1594 }
1595
1596