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

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

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