PluginProbe
Yatra – Travel Booking & Tour Operator Software / 3.0.8
Yatra – Travel Booking & Tour Operator Software v3.0.8
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.8, at app/Services/SettingsService.php

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