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

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