PluginProbe
Yatra – Travel Booking & Tour Operator Software / 3.0.16
Yatra – Travel Booking & Tour Operator Software v3.0.16
3.0.16 3.0.15 3.0.14 3.0.14.1 3.0.14.2 3.0.12 3.0.13 3.0.11 3.0.10 3.0.9 3.0.8 3.0.7 3.0.6 3.0.5 3.0.5.1 3.0.4 3.0.3 3.0.2.9 3.0.2.7 3.0.2.8 3.0.2.6 trunk 1.0.0 2.0.0 2.0.1 All 84 releases
yatra / app / Controllers / SettingsController.php

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

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