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

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