PluginProbe
Yatra – Travel Booking & Tour Operator Software / 3.0.7
Yatra – Travel Booking & Tour Operator Software v3.0.7
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 2.0.11 All 82 releases
yatra / app / Services / SettingsService.php

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

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