PluginProbe
Yatra – Travel Booking & Tour Operator Software / 3.0.17
Yatra – Travel Booking & Tour Operator Software v3.0.17
3.0.17 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 All 85 releases
yatra / app / Controllers / SettingsController.php

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

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