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

SettingsService.php in Yatra – Travel Booking & Tour Operator Software 3.0.10, at app/Services/SettingsService.php

1,075 lines 42.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\Services;
6
7 /**
8 * Centralized Settings Service
9 *
10 * Provides a single point of access for all plugin settings.
11 * Caches settings to avoid multiple database queries.
12 *
13 * @package Yatra
14 */
15 class SettingsService
16 {
17 /**
18 * Cached settings
19 */
20 private static ?array $settings = null;
21
22 /**
23 * Cached {@see self::getPermalinkBases()} per request (after {@see 'yatra_permalink_bases'} filter).
24 */
25 private static ?array $permalinkBasesCache = null;
26
27 /**
28 * Settings option prefix in database
29 * Each setting is stored as yatra_{key}
30 */
31 private const OPTION_PREFIX = 'yatra_';
32
33 /**
34 * Default settings
35 */
36 private static array $defaults = [
37 // General
38 'company_name' => '',
39 'company_email' => '',
40 'company_phone' => '',
41 'company_address' => '',
42 'timezone' => 'UTC',
43 'date_format' => 'Y-m-d',
44 'time_format' => 'H:i',
45 /** Primary brand color (hex) for trip/booking/listing frontend — see FrontendThemeCss */
46 'frontend_primary_color' => '#3b82f6',
47 /** Max width for Yatra trip/booking/listing containers (CSS length). Empty = theme.json / content width / filter. */
48 'frontend_container_max_width' => '',
49
50 // Booking
51 'booking_base' => 'book',
52 'use_booking_page' => false,
53 'booking_page_id' => 0,
54 'terms_page_id' => 0,
55 'privacy_policy_page_id' => 0,
56 'enable_guest_booking' => true,
57 'booking_confirmation' => true,
58 'auto_confirm_bookings' => false,
59 'require_login' => false,
60 'allow_guest_checkout' => true,
61 // Hold guest bookings in `pending_verification` status until
62 // the customer clicks a magic link sent to the email they
63 // gave. Defends against typo'd email addresses (a booking
64 // with the wrong email is unreachable forever) and against
65 // form-spam bots that submit junk emails. Only applies when
66 // `allow_guest_checkout` is true and the customer is not
67 // logged in.
68 'require_guest_email_verification' => false,
69 // cancellation_policy / cancellation_days / refund_policy
70 // removed — see SettingsController::$default_settings for
71 // the rationale. Leaving the keys out of this defaults map
72 // means SettingsService::get() returns null for legacy
73 // callers, and email/template render paths handle absence
74 // gracefully by skipping the cancellation paragraph or
75 // falling back to the per-trip cancellation_policy.
76 'booking_expiry_hours' => 24,
77 'booking_reminder_days' => 3,
78 'allow_waitlist' => true,
79 'waitlist_auto_confirm' => false,
80 // Pro: when enabled, the single-trip date_specific mode renders a
81 // <select> of available departure dates instead of the flatpickr
82 // calendar (desktop sidebar and mobile sticky bar). Renders no-op
83 // for free installs — see Settings UI + FrontendAssetsProvider gate.
84 'date_picker_as_dropdown' => false,
85
86 // Payment
87 'currency' => 'USD',
88 'payment_test_mode' => true,
89 'currency_position' => 'before',
90 'thousand_separator' => ',',
91 'decimal_separator' => '.',
92 'decimal_places' => 2,
93 // Flexible payments (deposit/partial) - Pro feature
94 // These defaults are overridden by Pro's FlexiblePaymentsModule when active
95 'enable_deposit' => false,
96 'deposit_type' => 'percentage',
97 'deposit_amount' => 20,
98 'deposit_required' => false,
99 'deposit_percentage' => 20,
100 'partial_payment' => false,
101 'partial_payment_percentage' => 30,
102 'auto_confirm_pay_later' => true,
103 'payment_gateways' => ['pay_later'],
104 'payment_methods' => [],
105 'gateway_configs' => [],
106 'gateway_order' => [],
107
108 'allow_save_payment_methods' => false,
109
110 // Email
111 'email_from_name' => '',
112 'email_from_address' => '',
113 'admin_email' => '',
114 'enable_admin_notifications' => true,
115 'enable_customer_notifications' => true,
116
117 // Email template enable flags.
118 //
119 // These mirror SettingsController::$default_settings + the
120 // entries InstallerService seeds on activation. They're
121 // duplicated here because SettingsService::isEnabled() falls
122 // back to THIS array when the wp_option doesn't exist — and
123 // there are two installation paths where the option is
124 // missing in production:
125 // 1. Sites that upgraded from a Yatra version that didn't
126 // seed the flag (InstallerService runs only on initial
127 // activation, not on update).
128 // 2. Sites whose operator never opened Settings → never
129 // hit the REST save endpoint that would write defaults.
130 // Without this fallback, the verification email + booking
131 // confirmation + every transactional email silently no-ops
132 // on those installs (sendIfEnabled gates on the flag).
133 'email_template_booking' => true,
134 'email_template_confirmation' => true,
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 // Customer-registration gate (AuthController::register reads
160 // this exact key). Mismatched name vs InstallerService's
161 // `enable_customer_registration` seed — keeping both names
162 // here so register() works regardless of which key was
163 // saved on prior installs.
164 'customer_registration' => true,
165
166 // Trip
167 'trip_base' => 'trip',
168 'trips_per_page' => 12,
169 'enable_wishlist' => false,
170 'enable_comparison' => false,
171 'show_sold_out' => true,
172
173 // Search & Listing storefront UX.
174 // Search-bar field visibility — default true so the bar renders every
175 // field exactly as before for existing free/pro installs. Owners can
176 // hide individual fields from Settings → Search & Listing.
177 'search_show_keyword' => true,
178 'search_show_destination' => true,
179 'search_show_activities' => true,
180 'search_show_duration' => true,
181 'search_show_budget' => true,
182 // Collapse the listing filter sidebar sections on mobile. Default false
183 // = today's behaviour (all sections expanded on every viewport), so an
184 // existing site sees no change on update until the owner opts in.
185 'collapse_filters_on_mobile' => false,
186
187 // Customer
188 'enable_customer_accounts' => true,
189 'enable_customer_registration' => true,
190 'customer_account_page' => 0,
191
192 // Review
193 'enable_reviews' => true,
194 'require_booking_to_review' => false,
195 'auto_approve_reviews' => false,
196 'enable_review_moderation' => true,
197 'minimum_rating' => 1,
198 'review_reminder_days' => 7,
199
200 // Tax
201 'enable_tax' => false,
202 'tax_rate' => 0,
203 'tax_inclusive' => false,
204 'tax_label' => 'Tax',
205 'multiple_taxes_enabled' => false,
206 'multiple_taxes' => [],
207 'multiple_taxes_by_country' => [],
208
209 // Currency
210 'enabled_currencies' => ['USD'],
211 'default_currency' => 'USD',
212
213 // Notification
214 'enable_push_notifications' => false,
215 'enable_sms_notifications' => false,
216
217 // Permalink
218 'destination_base' => 'destination',
219 'activity_base' => 'activity',
220 'trip_category_base' => 'trip-category',
221
222 // SEO
223 'enable_sitemap' => true,
224
225 // Advanced
226 'enable_debug_mode' => false,
227 'delete_data_on_uninstall' => false,
228
229 // Booking Form Builder
230 'booking_form_config' => [],
231 ];
232
233 /**
234 * Get default booking form configuration
235 *
236 * @return array
237 */
238 public static function getDefaultBookingFormConfig(): array
239 {
240 // User-facing strings (titles, descriptions, labels, placeholders,
241 // option labels) are wrapped in __() so they are (a) extracted into the
242 // .pot for Loco Translate and (b) translated to the active locale when
243 // the config is built — e.g. on a Dutch storefront the default booking
244 // form renders in Dutch. Structural values (id/type/order/width/etc.)
245 // stay literal. Saved/custom labels are additionally translated at
246 // render time (see yatra_translate_form_string()).
247 return [
248 'contact_form' => [
249 'title' => __('Lead Traveler / Contact Information', 'yatra'),
250 'description' => __('Primary contact person for this booking', 'yatra'),
251 'fields' => [
252 ['id' => 'first_name', 'type' => 'text', 'label' => __('First Name', 'yatra'), 'placeholder' => __('Enter first name', 'yatra'), 'required' => true, 'enabled' => true, 'order' => 1, 'width' => 'half', 'locked' => true],
253 ['id' => 'last_name', 'type' => 'text', 'label' => __('Last Name', 'yatra'), 'placeholder' => __('Enter last name', 'yatra'), 'required' => true, 'enabled' => true, 'order' => 2, 'width' => 'half', 'locked' => true],
254 ['id' => 'email', 'type' => 'email', 'label' => __('Email Address', 'yatra'), 'placeholder' => 'your@email.com', 'required' => true, 'enabled' => true, 'order' => 3, 'width' => 'half', 'locked' => true],
255 ['id' => 'phone', 'type' => 'tel', 'label' => __('Phone Number', 'yatra'), 'placeholder' => '+1 234 567 8900', 'required' => true, 'enabled' => true, 'order' => 4, 'width' => 'half', 'locked' => true],
256 ['id' => 'country', 'type' => 'country', 'label' => __('Country', 'yatra'), 'placeholder' => __('Select Country', 'yatra'), 'required' => true, 'enabled' => true, 'order' => 5, 'width' => 'half', 'locked' => true],
257 ['id' => 'nationality', 'type' => 'country', 'label' => __('Nationality', 'yatra'), 'placeholder' => __('Select Nationality', 'yatra'), 'required' => false, 'enabled' => true, 'order' => 6, 'width' => 'half'],
258 ['id' => 'address', 'type' => 'text', 'label' => __('Address', 'yatra'), 'placeholder' => __('Street address (optional)', 'yatra'), 'required' => false, 'enabled' => true, 'order' => 7, 'width' => 'full'],
259 ],
260 ],
261 'emergency_contact_form' => [
262 'title' => __('Emergency Contact', 'yatra'),
263 'description' => __('Person to contact in case of emergency', 'yatra'),
264 'enabled' => true,
265 'fields' => [
266 ['id' => 'name', 'type' => 'text', 'label' => __('Contact Name', 'yatra'), 'placeholder' => __('Full name', 'yatra'), 'required' => true, 'enabled' => true, 'order' => 1, 'width' => 'half'],
267 ['id' => 'phone', 'type' => 'tel', 'label' => __('Contact Phone', 'yatra'), 'placeholder' => '+1 234 567 8900', 'required' => true, 'enabled' => true, 'order' => 2, 'width' => 'half'],
268 ['id' => 'relationship', 'type' => 'select', 'label' => __('Relationship', 'yatra'), 'placeholder' => __('Select Relationship', 'yatra'), 'required' => false, 'enabled' => true, 'order' => 3, 'width' => 'full', 'options' => [
269 ['value' => 'spouse', 'label' => __('Spouse/Partner', 'yatra')],
270 ['value' => 'parent', 'label' => __('Parent', 'yatra')],
271 ['value' => 'sibling', 'label' => __('Sibling', 'yatra')],
272 ['value' => 'child', 'label' => __('Child', 'yatra')],
273 ['value' => 'friend', 'label' => __('Friend', 'yatra')],
274 ['value' => 'other', 'label' => __('Other', 'yatra')],
275 ]],
276 ],
277 ],
278 'traveler_form' => [
279 'title' => __('Traveler Information', 'yatra'),
280 'description' => __('Please provide details for each traveler', 'yatra'),
281 'fields' => [
282 ['id' => 'first_name', 'type' => 'text', 'label' => __('First Name', 'yatra'), 'placeholder' => __('Legal first name', 'yatra'), 'required' => true, 'enabled' => true, 'order' => 1, 'width' => 'half'],
283 ['id' => 'last_name', 'type' => 'text', 'label' => __('Last Name', 'yatra'), 'placeholder' => __('Legal last name', 'yatra'), 'required' => true, 'enabled' => true, 'order' => 2, 'width' => 'half'],
284 ['id' => 'date_of_birth', 'type' => 'date', 'label' => __('Date of Birth', 'yatra'), 'placeholder' => '', 'required' => true, 'enabled' => true, 'order' => 3, 'width' => 'half'],
285 ['id' => 'gender', 'type' => 'select', 'label' => __('Gender', 'yatra'), 'placeholder' => __('Select Gender', 'yatra'), 'required' => true, 'enabled' => true, 'order' => 4, 'width' => 'half', 'options' => [
286 ['value' => 'male', 'label' => __('Male', 'yatra')],
287 ['value' => 'female', 'label' => __('Female', 'yatra')],
288 ['value' => 'other', 'label' => __('Other', 'yatra')],
289 ]],
290 ['id' => 'nationality', 'type' => 'country', 'label' => __('Nationality', 'yatra'), 'placeholder' => __('Select Nationality', 'yatra'), 'required' => true, 'enabled' => true, 'order' => 5, 'width' => 'full'],
291 ['id' => 'dietary', 'type' => 'select', 'label' => __('Dietary Requirements', 'yatra'), 'placeholder' => __('Select', 'yatra'), 'required' => false, 'enabled' => true, 'order' => 6, 'width' => 'half', 'section' => 'dietary_medical', 'options' => [
292 ['value' => 'none', 'label' => __('No special requirements', 'yatra')],
293 ['value' => 'vegetarian', 'label' => __('Vegetarian', 'yatra')],
294 ['value' => 'vegan', 'label' => __('Vegan', 'yatra')],
295 ['value' => 'halal', 'label' => __('Halal', 'yatra')],
296 ['value' => 'kosher', 'label' => __('Kosher', 'yatra')],
297 ['value' => 'gluten_free', 'label' => __('Gluten Free', 'yatra')],
298 ['value' => 'lactose_free', 'label' => __('Lactose Free', 'yatra')],
299 ['value' => 'other', 'label' => __('Other (specify in notes)', 'yatra')],
300 ]],
301 ['id' => 'medical', 'type' => 'text', 'label' => __('Medical Conditions / Allergies', 'yatra'), 'placeholder' => __('Any allergies or conditions we should know', 'yatra'), 'required' => false, 'enabled' => true, 'order' => 7, 'width' => 'half', 'section' => 'dietary_medical'],
302 ],
303 ],
304 ];
305 }
306
307 /**
308 * Get booking form configuration (merged with defaults)
309 *
310 * @return array
311 */
312 public static function getBookingFormConfig(): array
313 {
314 $saved_config = self::get('booking_form_config', []);
315 $default_config = self::getDefaultBookingFormConfig();
316
317 // If no saved config, return defaults (Pro may filter)
318 if (empty($saved_config)) {
319 return apply_filters('yatra_booking_form_config', $default_config);
320 }
321
322 // Merge saved over defaults. IMPORTANT: `fields` is a positional list,
323 // so a naive array_replace_recursive() merges field-by-INDEX — which
324 // resurrects a deleted default field (saved list is shorter, the tail
325 // default leaks back) and duplicates fields after a middle deletion.
326 // We therefore merge each section's fields BY `id`, treating the saved
327 // config as the authoritative list (order, props, and deletions), while
328 // guaranteeing that locked core fields always exist and stay
329 // locked+required.
330 $merged = [];
331 foreach ($default_config as $form_type => $default_section) {
332 $saved_section = is_array($saved_config[$form_type] ?? null)
333 ? $saved_config[$form_type]
334 : null;
335
336 if ($saved_section === null) {
337 // Section absent from saved config → use the default verbatim.
338 $merged[$form_type] = $default_section;
339 continue;
340 }
341
342 // Section-level scalars (title/description/enabled) come from saved,
343 // falling back to default.
344 $section = array_merge($default_section, $saved_section);
345
346 // Index default fields by id + collect the locked ids for this section.
347 $default_fields_by_id = [];
348 $locked_ids = [];
349 foreach (($default_section['fields'] ?? []) as $df) {
350 if (empty($df['id'])) {
351 continue;
352 }
353 $default_fields_by_id[$df['id']] = $df;
354 if (!empty($df['locked'])) {
355 $locked_ids[$df['id']] = true;
356 }
357 }
358
359 // Rebuild the field list from the saved order, de-duplicated by id.
360 $result_fields = [];
361 $seen = [];
362 $saved_fields = is_array($saved_section['fields'] ?? null)
363 ? $saved_section['fields']
364 : ($default_section['fields'] ?? []);
365 foreach ($saved_fields as $sf) {
366 $id = is_array($sf) ? ($sf['id'] ?? '') : '';
367 if ($id === '' || isset($seen[$id])) {
368 continue; // drop malformed / duplicate field entries
369 }
370 $seen[$id] = true;
371 // Known default field → default props as the base, saved wins.
372 $field = isset($default_fields_by_id[$id])
373 ? array_merge($default_fields_by_id[$id], $sf)
374 : $sf;
375 if (isset($locked_ids[$id])) {
376 $field['locked'] = true;
377 $field['required'] = true;
378 // Locked core fields must keep their original input type — a
379 // saved config can't repurpose them (e.g. to a display-only
380 // text_block), which would drop the real input from checkout.
381 if (isset($default_fields_by_id[$id]['type'])) {
382 $field['type'] = $default_fields_by_id[$id]['type'];
383 }
384 }
385 $result_fields[] = $field;
386 }
387
388 // Locked core fields can never be legitimately removed — re-add any
389 // that the saved config dropped, so checkout/admin always have them.
390 foreach ($locked_ids as $id => $_) {
391 if (!isset($seen[$id])) {
392 $field = $default_fields_by_id[$id];
393 $field['locked'] = true;
394 $field['required'] = true;
395 $result_fields[] = $field;
396 }
397 }
398
399 $section['fields'] = $result_fields;
400 $merged[$form_type] = $section;
401 }
402
403 // Preserve any saved sections that aren't part of the defaults
404 // (future-proofing for Pro-introduced sections).
405 foreach ($saved_config as $form_type => $saved_section) {
406 if (!isset($merged[$form_type])) {
407 $merged[$form_type] = $saved_section;
408 }
409 }
410
411 return apply_filters('yatra_booking_form_config', $merged);
412 }
413
414 private static function isEmailIdentityKey(string $key): bool
415 {
416 return $key === 'admin_email' || $key === 'from_email' || $key === 'from_name';
417 }
418
419 private static function isEmptyScalar($value): bool
420 {
421 return $value === null || $value === false || $value === ''
422 || (is_string($value) && trim($value) === '');
423 }
424
425 /**
426 * When Yatra delivery options are empty, use WordPress site admin email / blog name (same as installer defaults).
427 *
428 * @param mixed $value
429 * @return mixed
430 */
431 private static function applyEmailIdentityFallback(string $key, $value)
432 {
433 if (!self::isEmailIdentityKey($key) || !self::isEmptyScalar($value)) {
434 return $value;
435 }
436 if ($key === 'from_name') {
437 $wp = (string) get_bloginfo('name');
438
439 return $wp !== '' ? $wp : $value;
440 }
441 $wp = (string) get_option('admin_email', '');
442
443 return $wp !== '' ? $wp : $value;
444 }
445
446 /**
447 * Get all settings
448 *
449 * @return array All settings with defaults applied
450 */
451 public static function all(): array
452 {
453 if (self::$settings === null) {
454 self::load();
455 }
456
457 return self::$settings;
458 }
459
460 /**
461 * Get setting value with fallback to default
462 *
463 * @param string $key Setting key
464 * @param mixed $default Default value if setting not found
465 * @return mixed Setting value or default
466 */
467 public static function get(string $key, $default = null)
468 {
469 if (self::$settings === null) {
470 self::load();
471 }
472
473 if (self::isScheduledPaymentSetting($key)) {
474 $scheduledDefaults = self::scheduledPaymentDefaults();
475
476 return apply_filters(
477 'yatra_scheduled_payment_setting',
478 $default ?? ($scheduledDefaults[$key] ?? null),
479 $key
480 );
481 }
482
483 // Support dot notation for nested access (future use)
484 if (strpos($key, '.') !== false) {
485 $keys = explode('.', $key);
486 $value = self::$settings;
487 foreach ($keys as $k) {
488 if (!isset($value[$k])) {
489 return $default ?? (self::$defaults[$key] ?? null);
490 }
491 $value = $value[$k];
492 }
493 return $value;
494 }
495
496 // If setting exists in cache, return it
497 if (isset(self::$settings[$key])) {
498 return self::applyEmailIdentityFallback($key, self::$settings[$key]);
499 }
500
501 // Try to fetch from database directly for settings not in defaults
502 $option_name = self::OPTION_PREFIX . $key;
503 $value = get_option($option_name, null);
504
505 // Installer / migrations used yatra_email_from_*; REST + EmailService use yatra_from_*.
506 if (($value === null || $value === false || $value === '') && $key === 'from_email') {
507 $legacy = get_option(self::OPTION_PREFIX . 'email_from_address', '');
508 if (is_string($legacy) && $legacy !== '') {
509 $value = $legacy;
510 }
511 }
512 if (($value === null || $value === false || $value === '') && $key === 'from_name') {
513 $legacy = get_option(self::OPTION_PREFIX . 'email_from_name', '');
514 if (is_string($legacy) && $legacy !== '') {
515 $value = $legacy;
516 }
517 }
518
519 if ($value !== null) {
520 // Handle serialized arrays
521 if (is_string($value) && is_serialized($value)) {
522 $value = maybe_unserialize($value);
523 }
524 // Cache the value
525 self::$settings[$key] = $value;
526
527 return self::applyEmailIdentityFallback($key, $value);
528 }
529
530 $fallback = $default ?? (self::$defaults[$key] ?? null);
531
532 return self::applyEmailIdentityFallback($key, $fallback);
533 }
534
535 /**
536 * Check if a boolean setting is enabled
537 *
538 * @param string $key Setting key
539 * @return bool
540 */
541 public static function isEnabled(string $key): bool
542 {
543 // Flexible payment settings require Pro module
544 if (self::isFlexiblePaymentSetting($key)) {
545 $value = apply_filters('yatra_flexible_payment_setting', false, $key);
546 return filter_var($value, FILTER_VALIDATE_BOOLEAN);
547 }
548
549 if (self::isScheduledPaymentSetting($key)) {
550 $defaults = self::scheduledPaymentDefaults();
551 $base = $defaults[$key] ?? false;
552 $value = apply_filters('yatra_scheduled_payment_setting', $base, $key);
553
554 return filter_var($value, FILTER_VALIDATE_BOOLEAN);
555 }
556
557 $value = self::get($key, false);
558 return filter_var($value, FILTER_VALIDATE_BOOLEAN);
559 }
560
561 /**
562 * Get a setting as integer
563 *
564 * @param string $key Setting key
565 * @param int $default Default value
566 * @return int
567 */
568 public static function getInt(string $key, int $default = 0): int
569 {
570 // Flexible payment settings require Pro module
571 if (self::isFlexiblePaymentSetting($key)) {
572 return (int) apply_filters('yatra_flexible_payment_setting', $default, $key);
573 }
574
575 if (self::isScheduledPaymentSetting($key)) {
576 $defaults = self::scheduledPaymentDefaults();
577 $base = $defaults[$key] ?? $default;
578
579 return (int) apply_filters('yatra_scheduled_payment_setting', $base, $key);
580 }
581
582 return (int) self::get($key, $default);
583 }
584
585 /**
586 * Get a setting as float
587 *
588 * @param string $key Setting key
589 * @param float $default Default value
590 * @return float
591 */
592 public static function getFloat(string $key, float $default = 0.0): float
593 {
594 return (float) self::get($key, $default);
595 }
596
597 /**
598 * Get a setting as string
599 *
600 * @param string $key Setting key
601 * @param string $default Default value
602 * @return string
603 */
604 public static function getString(string $key, string $default = ''): string
605 {
606 return (string) self::get($key, $default);
607 }
608
609 /**
610 * Load settings from database
611 * Settings are stored as individual options with yatra_ prefix
612 */
613 private static function load(): void
614 {
615 self::$settings = [];
616
617 // Load each setting from individual options
618 foreach (self::$defaults as $key => $default_value) {
619 $option_name = self::OPTION_PREFIX . $key;
620 $value = get_option($option_name, $default_value);
621
622 // Handle serialized arrays
623 if (is_string($value) && is_serialized($value)) {
624 $value = maybe_unserialize($value);
625 }
626
627 self::$settings[$key] = $value;
628 }
629
630 self::mergeAdminReviewOptionAliases();
631 }
632
633 /**
634 * REST/Settings UI uses yatra_require_booking, yatra_review_moderation, yatra_min_rating;
635 * internal helpers use require_booking_to_review, enable_review_moderation, minimum_rating.
636 */
637 private static function mergeAdminReviewOptionAliases(): void
638 {
639 $map = [
640 'require_booking' => 'require_booking_to_review',
641 'review_moderation' => 'enable_review_moderation',
642 'min_rating' => 'minimum_rating',
643 ];
644 foreach ($map as $adminKey => $internalKey) {
645 $v = get_option(self::OPTION_PREFIX . $adminKey, null);
646 if ($v !== null) {
647 self::$settings[$internalKey] = $v;
648 }
649 }
650 }
651
652 /**
653 * Reload settings (clear cache)
654 */
655 public static function reload(): void
656 {
657 self::$settings = null;
658 self::$permalinkBasesCache = null;
659 self::load();
660 }
661
662 /**
663 * Get default settings
664 *
665 * @return array
666 */
667 public static function getDefaults(): array
668 {
669 return self::$defaults;
670 }
671
672 // =========================================
673 // Convenience Methods for Common Settings
674 // =========================================
675
676 /**
677 * Check if reviews are enabled
678 */
679 public static function reviewsEnabled(): bool
680 {
681 return self::isEnabled('enable_reviews');
682 }
683
684 /**
685 * Check if booking is required for reviews
686 */
687 public static function requireBookingForReview(): bool
688 {
689 return self::isEnabled('require_booking_to_review');
690 }
691
692 /**
693 * Check if reviews auto-approve
694 */
695 public static function autoApproveReviews(): bool
696 {
697 return self::isEnabled('auto_approve_reviews');
698 }
699
700 /**
701 * Check if review moderation is enabled
702 */
703 public static function reviewModerationEnabled(): bool
704 {
705 return self::isEnabled('enable_review_moderation');
706 }
707
708 /**
709 * Get minimum rating allowed
710 */
711 public static function getMinimumRating(): int
712 {
713 return self::getInt('minimum_rating', 1);
714 }
715
716 /**
717 * Get currency settings
718 * Checks both 'currency' and 'default_currency' keys for compatibility
719 * (Admin UI Currency Settings saves as 'default_currency')
720 */
721 public static function getCurrency(): string
722 {
723 // Priority: 'currency' key first (Payment Settings), then 'default_currency' (Currency Settings)
724 $currency = self::getString('currency', '');
725 if (!empty($currency) && $currency !== 'USD') {
726 return $currency;
727 }
728
729 // Check default_currency (from Currency Settings section)
730 $defaultCurrency = self::getString('default_currency', '');
731 if (!empty($defaultCurrency)) {
732 return $defaultCurrency;
733 }
734
735 // Return whatever currency is set, even if USD
736 return !empty($currency) ? $currency : 'USD';
737 }
738
739 /**
740 * Get currency position (before/after)
741 */
742 public static function getCurrencyPosition(): string
743 {
744 return self::getString('currency_position', 'before');
745 }
746
747 /**
748 * Single source of truth for the number of decimals shown in prices.
749 *
750 * Historically two unsynced options existed:
751 * - `currency_decimals` — the admin "Number of decimals" field, also handed
752 * to the frontend JS as `decimalPlaces`. Written only when settings are saved.
753 * - `decimal_places` — legacy, written by the installer (default 2) and the
754 * Setup Wizard, and read by {@see yatra_format_price()}.
755 *
756 * They drifted, so PHP-rendered prices (single trip, showcase, listings) and
757 * JS-rendered prices could disagree, and the admin field had no effect on PHP.
758 * This resolver collapses both into ONE value that every reader uses:
759 * 1. the admin field when it has been changed from the default (authoritative);
760 * 2. otherwise a non-default legacy value (preserves Setup-Wizard choices);
761 * 3. otherwise whichever is present, else the default.
762 *
763 * Result is clamped to 0–4. It can never silently regress a site that was
764 * already showing the correct decimals — it only aligns the two readers.
765 */
766 public static function getPriceDecimals(): int
767 {
768 $default = 2;
769
770 $cdRaw = get_option('yatra_currency_decimals', null); // admin field + JS
771 $dpRaw = get_option('yatra_decimal_places', null); // legacy / yatra_format_price
772
773 $cd = ($cdRaw === null || $cdRaw === '') ? null : (int) $cdRaw;
774 $dp = ($dpRaw === null || $dpRaw === '') ? null : (int) $dpRaw;
775
776 if ($cd !== null && $cd !== $default) {
777 $value = $cd; // admin explicitly changed → wins
778 } elseif ($dp !== null && $dp !== $default) {
779 $value = $dp; // legacy Setup-Wizard value → preserved
780 } elseif ($cd !== null) {
781 $value = $cd; // admin field present at default
782 } elseif ($dp !== null) {
783 $value = $dp;
784 } else {
785 $value = $default;
786 }
787
788 return max(0, min(4, $value));
789 }
790
791 /**
792 * Sanitize a single URL path segment used in Yatra rewrites (alphanumeric, underscore, hyphen).
793 */
794 private static function sanitizePermalinkSlug(string $value, string $fallback): string
795 {
796 $v = preg_replace('/[^a-z0-9_-]/i', '', $value);
797
798 return ($v !== '' && is_string($v)) ? $v : $fallback;
799 }
800
801 /**
802 * Default account path slug (before {@see 'yatra_permalink_bases'}).
803 */
804 private static function resolveDefaultAccountBaseSlug(): string
805 {
806 $customerPath = get_option('yatra_customer_account_page', '');
807 if (is_string($customerPath) && $customerPath !== '' && $customerPath !== '0') {
808 $slug = self::slugFromAccountPathString($customerPath);
809 if ($slug !== '') {
810 return self::sanitizePermalinkSlug($slug, 'account');
811 }
812 }
813
814 $base = self::getString('account_base', '');
815 $base = self::sanitizePermalinkSlug($base, '');
816
817 return $base !== '' ? $base : 'account';
818 }
819
820 /**
821 * Raw permalink configuration from options (not yet filtered).
822 *
823 * @return array<string, string>
824 */
825 private static function defaultPermalinkBases(): array
826 {
827 $trip = self::sanitizePermalinkSlug(self::getString('trip_base', 'trip'), 'trip');
828 $booking = self::sanitizePermalinkSlug(self::getString('booking_base', 'booking'), 'booking');
829 $account = self::resolveDefaultAccountBaseSlug();
830 $destination = self::sanitizePermalinkSlug(self::getString('destination_base', 'destination'), 'destination');
831 $activity = self::sanitizePermalinkSlug(self::getString('activity_base', 'activity'), 'activity');
832 $tripCategory = self::sanitizePermalinkSlug(self::getString('trip_category_base', 'trip-category'), 'trip-category');
833
834 return [
835 'trip_base' => $trip,
836 'booking_base' => $booking,
837 'account_base' => $account,
838 'destination_base' => $destination,
839 'activity_base' => $activity,
840 'trip_category_base' => $tripCategory,
841 /** Path segment after booking base for confirmation URLs, e.g. /{booking_base}/confirmation/{ref}/ */
842 'booking_flow_confirmation_segment' => 'confirmation',
843 /** Legacy pageless path /{prefix}/{reference}/ (default kept for old links). */
844 'legacy_booking_confirmation_prefix' => 'booking-confirmation',
845 /** Pageless remaining balance checkout /{prefix}/{token}/ */
846 'remaining_checkout_prefix' => 'remaining-checkout',
847 /** Email verification pretty path /{prefix}/{token}/ */
848 'email_verification_prefix' => 'yatra-verify-email',
849 ];
850 }
851
852 /**
853 * All path segments and prefixes used by Yatra rewrites, routing, and URL helpers.
854 *
855 * Third-party plugins can change slugs in one place via:
856 *
857 * `add_filter( 'yatra_permalink_bases', function ( array $bases ) { $bases['trip_base'] = 'tours'; return $bases; } );`
858 *
859 * **Full URLs (different from bases only):**
860 *
861 * - Outbound links: `yatra_destination_permalink`, `yatra_activity_permalink`, `yatra_category_permalink`, `yatra_trip_permalink`
862 * ({@see yatra_get_destination_permalink()} and siblings in `includes/helpers.php`).
863 * - Inbound path mapping (pretty URLs): {@see \Yatra\Core\Routing\UrlParser::getCleanRequestPath()} filter `yatra_frontend_request_path`.
864 * - Inbound overrides: `yatra_pretty_route_match`, `yatra_plain_route_match` ({@see \Yatra\Core\Routing\PrettyRouteMatcher}, {@see \Yatra\Core\Routing\PlainPageMatcher}).
865 *
866 * After changing bases at runtime you must flush rewrite rules (or bump `yatra_rewrite_rules_version`
867 * in development). Use the {@see 'yatra_register_rewrite_rules'} action to register extra rules that
868 * depend on these bases.
869 *
870 * @return array<string, string>
871 */
872 public static function getPermalinkBases(): array
873 {
874 if (self::$permalinkBasesCache !== null) {
875 return self::$permalinkBasesCache;
876 }
877
878 $defaults = self::defaultPermalinkBases();
879 $filtered = apply_filters('yatra_permalink_bases', $defaults);
880 if (!is_array($filtered)) {
881 $filtered = $defaults;
882 }
883
884 $merged = array_merge($defaults, $filtered);
885 $out = [
886 'trip_base' => self::sanitizePermalinkSlug((string) ($merged['trip_base'] ?? ''), $defaults['trip_base']),
887 'booking_base' => self::sanitizePermalinkSlug((string) ($merged['booking_base'] ?? ''), $defaults['booking_base']),
888 'account_base' => self::sanitizePermalinkSlug((string) ($merged['account_base'] ?? ''), $defaults['account_base']),
889 'destination_base' => self::sanitizePermalinkSlug((string) ($merged['destination_base'] ?? ''), $defaults['destination_base']),
890 'activity_base' => self::sanitizePermalinkSlug((string) ($merged['activity_base'] ?? ''), $defaults['activity_base']),
891 'trip_category_base' => self::sanitizePermalinkSlug((string) ($merged['trip_category_base'] ?? ''), $defaults['trip_category_base']),
892 'booking_flow_confirmation_segment' => self::sanitizePermalinkSlug(
893 (string) ($merged['booking_flow_confirmation_segment'] ?? ''),
894 $defaults['booking_flow_confirmation_segment']
895 ),
896 'legacy_booking_confirmation_prefix' => self::sanitizePermalinkSlug(
897 (string) ($merged['legacy_booking_confirmation_prefix'] ?? ''),
898 $defaults['legacy_booking_confirmation_prefix']
899 ),
900 'remaining_checkout_prefix' => self::sanitizePermalinkSlug(
901 (string) ($merged['remaining_checkout_prefix'] ?? ''),
902 $defaults['remaining_checkout_prefix']
903 ),
904 'email_verification_prefix' => self::sanitizePermalinkSlug(
905 (string) ($merged['email_verification_prefix'] ?? ''),
906 $defaults['email_verification_prefix']
907 ),
908 ];
909
910 self::$permalinkBasesCache = $out;
911
912 return self::$permalinkBasesCache;
913 }
914
915 /**
916 * Get trip base slug
917 */
918 public static function getTripBase(): string
919 {
920 return self::getPermalinkBases()['trip_base'];
921 }
922
923 /**
924 * Get booking base slug
925 */
926 public static function getBookingBase(): string
927 {
928 return self::getPermalinkBases()['booking_base'];
929 }
930
931 /**
932 * URL slug for the customer account area (Settings → Customer → account path).
933 * Derives from yatra_customer_account_page first so routing matches the configured path
934 * even when yatra_account_base was never saved or is out of sync.
935 */
936 public static function getAccountBase(): string
937 {
938 return self::getPermalinkBases()['account_base'];
939 }
940
941 public static function getDestinationBase(): string
942 {
943 return self::getPermalinkBases()['destination_base'];
944 }
945
946 public static function getActivityBase(): string
947 {
948 return self::getPermalinkBases()['activity_base'];
949 }
950
951 public static function getTripCategoryBase(): string
952 {
953 return self::getPermalinkBases()['trip_category_base'];
954 }
955
956 private static function slugFromAccountPathString(string $path): string
957 {
958 $path = trim(str_replace('\\', '/', $path), '/');
959 $parts = array_values(array_filter(explode('/', $path), static fn ($p) => $p !== ''));
960 $segment = $parts !== [] ? end($parts) : 'account';
961 $slug = sanitize_title($segment);
962
963 return $slug !== '' ? $slug : 'account';
964 }
965
966 /**
967 * Check if using custom booking page
968 */
969 public static function useCustomBookingPage(): bool
970 {
971 return self::isEnabled('use_booking_page') && self::getInt('booking_page_id') > 0;
972 }
973
974 /**
975 * Get booking page ID
976 */
977 public static function getBookingPageId(): int
978 {
979 return self::getInt('booking_page_id', 0);
980 }
981
982 /**
983 * Check if guest booking is allowed
984 */
985 public static function guestBookingEnabled(): bool
986 {
987 return self::isEnabled('enable_guest_booking');
988 }
989
990 /**
991 * Wishlist (saved trips) is a Yatra Pro feature and must be enabled in settings.
992 */
993 public static function wishlistEnabled(): bool
994 {
995 if (!apply_filters('yatra_is_pro_active', false)) {
996 return false;
997 }
998
999 return self::isEnabled('enable_wishlist');
1000 }
1001
1002 /**
1003 * Trips per page on front-end listings (aligned with WordPress Reading "posts per page").
1004 */
1005 public static function getTripsPerPage(): int
1006 {
1007 if (function_exists('yatra_get_posts_per_page')) {
1008 return yatra_get_posts_per_page();
1009 }
1010
1011 return max(1, absint((int) get_option('posts_per_page', 10)));
1012 }
1013
1014 /**
1015 * Check if a setting key is a flexible payment setting (Pro feature)
1016 *
1017 * @param string $key Setting key
1018 * @return bool
1019 */
1020 private static function isFlexiblePaymentSetting(string $key): bool
1021 {
1022 $flexiblePaymentSettings = [
1023 'deposit_required',
1024 'deposit_percentage',
1025 'partial_payment',
1026 'partial_payment_percentage',
1027 'enable_deposit',
1028 'allow_save_payment_methods',
1029 ];
1030
1031 return in_array($key, $flexiblePaymentSettings, true);
1032 }
1033
1034 /**
1035 * Settings owned by Yatra Pro "Scheduled Payments" module (not core options).
1036 *
1037 * @return array<string, mixed>
1038 */
1039 private static function scheduledPaymentDefaults(): array
1040 {
1041 return [
1042 'enable_scheduled_payments' => false,
1043 'scheduled_payment_type' => 'single',
1044 'scheduled_payment_days' => 15,
1045 'scheduled_payment_installments' => 1,
1046 'scheduled_payment_interval' => 30,
1047 'scheduled_payment_reminder_days' => 3,
1048 ];
1049 }
1050
1051 private static function isScheduledPaymentSetting(string $key): bool
1052 {
1053 return array_key_exists($key, self::scheduledPaymentDefaults());
1054 }
1055
1056 /**
1057 * Check if flexible payments module is available (Pro active + module enabled)
1058 *
1059 * @return bool
1060 */
1061 public static function isFlexiblePaymentsAvailable(): bool
1062 {
1063 return apply_filters('yatra_flexible_payments_enabled', false);
1064 }
1065
1066 /**
1067 * Global payment test/sandbox toggle (Settings → Payment).
1068 */
1069 public static function isPaymentTestMode(): bool
1070 {
1071 return self::isEnabled('payment_test_mode');
1072 }
1073 }
1074
1075