PluginProbe
Yatra – Travel Booking & Tour Operator Software / 3.0.17
Yatra – Travel Booking & Tour Operator Software v3.0.17
3.0.17 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 All 85 releases
yatra / app / Services / SettingsService.php

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

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