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
← All changes | app/Services/SettingsService.php +526 -102 3.0.2.6 → 3.0.17 View file →
@@ -19,8 +19,13 @@
19 19 */
20 20 private static ?array $settings = null;
21 21
22 22 /**
23 + * Cached {@see self::getPermalinkBases()} per request (after {@see 'yatra_permalink_bases'} filter).
24 + */
25 + private static ?array $permalinkBasesCache = null;
26 +
27 + /**
23 28 * Settings option prefix in database
24 29 * Each setting is stored as yatra_{key}
25 30 */
26 31 private const OPTION_PREFIX = 'yatra_';
@@ -33,28 +38,63 @@
33 38 'company_name' => '',
34 39 'company_email' => '',
35 40 'company_phone' => '',
36 41 'company_address' => '',
42 + 'timezone' => 'UTC',
37 43 'date_format' => 'Y-m-d',
38 44 'time_format' => 'H:i',
39 -
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 +
40 52 // Booking
41 53 'booking_base' => 'book',
42 54 'use_booking_page' => false,
43 55 'booking_page_id' => 0,
56 + 'terms_page_id' => 0,
57 + 'privacy_policy_page_id' => 0,
44 58 'enable_guest_booking' => true,
45 59 'booking_confirmation' => true,
46 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',
47 66 'require_login' => false,
48 67 'allow_guest_checkout' => true,
49 - 'cancellation_policy' => 'full_refund',
50 - 'cancellation_days' => 7,
51 - 'refund_policy' => '',
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.
52 83 'booking_expiry_hours' => 24,
53 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,
54 89 'allow_waitlist' => true,
55 90 'waitlist_auto_confirm' => false,
56 -
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 +
57 97 // Payment
58 98 'currency' => 'USD',
59 99 'payment_test_mode' => true,
60 100 'currency_position' => 'before',
@@ -75,16 +115,8 @@
75 115 'payment_methods' => [],
76 116 'gateway_configs' => [],
77 117 'gateway_order' => [],
78 118
79 - // Scheduled/Recurring Payments - Pro feature
80 - // These defaults are overridden by Pro's FlexiblePaymentsModule when active
81 - 'enable_scheduled_payments' => false,
82 - 'scheduled_payment_type' => 'single',
83 - 'scheduled_payment_days' => 15,
84 - 'scheduled_payment_installments' => 1,
85 - 'scheduled_payment_interval' => 30,
86 - 'scheduled_payment_reminder_days' => 3,
87 119 'allow_save_payment_methods' => false,
88 120
89 121 // Email
90 122 'email_from_name' => '',
@@ -91,8 +123,75 @@
91 123 'email_from_address' => '',
92 124 'admin_email' => '',
93 125 'enable_admin_notifications' => true,
94 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,
95 194
96 195 // Trip
97 196 'trip_base' => 'trip',
98 197 'trips_per_page' => 12,
@@ -98,9 +197,27 @@
98 197 'trips_per_page' => 12,
99 198 'enable_wishlist' => false,
100 199 'enable_comparison' => false,
101 200 'show_sold_out' => true,
102 -
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 +
103 220 // Customer
104 221 'enable_customer_accounts' => true,
105 222 'enable_customer_registration' => true,
106 223 'customer_account_page' => 0,
@@ -110,9 +227,13 @@
110 227 'require_booking_to_review' => false,
111 228 'auto_approve_reviews' => false,
112 229 'enable_review_moderation' => true,
113 230 'minimum_rating' => 1,
114 - 'review_reminder_days' => 7,
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,
115 236
116 237 // Tax
117 238 'enable_tax' => false,
118 239 'tax_rate' => 0,
@@ -134,8 +255,18 @@
134 255 'destination_base' => 'destination',
135 256 'activity_base' => 'activity',
136 257 'trip_category_base' => 'trip-category',
137 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 +
138 269 // Advanced
139 270 'enable_debug_mode' => false,
140 271 'delete_data_on_uninstall' => false,
141 272
@@ -149,63 +280,70 @@
149 280 * @return array
150 281 */
151 282 public static function getDefaultBookingFormConfig(): array
152 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()).
153 291 return [
154 292 'contact_form' => [
155 - 'title' => 'Lead Traveler / Contact Information',
156 - 'description' => 'Primary contact person for this booking',
293 + 'title' => __('Lead Traveler / Contact Information', 'yatra'),
294 + 'description' => __('Primary contact person for this booking', 'yatra'),
157 295 'fields' => [
158 - ['id' => 'first_name', 'type' => 'text', 'label' => 'First Name', 'placeholder' => 'Enter first name', 'required' => true, 'enabled' => true, 'order' => 1, 'width' => 'half', 'locked' => true],
159 - ['id' => 'last_name', 'type' => 'text', 'label' => 'Last Name', 'placeholder' => 'Enter last name', 'required' => true, 'enabled' => true, 'order' => 2, 'width' => 'half', 'locked' => true],
160 - ['id' => 'email', 'type' => 'email', 'label' => 'Email Address', 'placeholder' => '[email protected]', 'required' => true, 'enabled' => true, 'order' => 3, 'width' => 'half', 'locked' => true],
161 - ['id' => 'phone', 'type' => 'tel', 'label' => 'Phone Number', 'placeholder' => '+1 234 567 8900', 'required' => true, 'enabled' => true, 'order' => 4, 'width' => 'half', 'locked' => true],
162 - ['id' => 'country', 'type' => 'country', 'label' => 'Country', 'placeholder' => 'Select Country', 'required' => true, 'enabled' => true, 'order' => 5, 'width' => 'half', 'locked' => true],
163 - ['id' => 'nationality', 'type' => 'country', 'label' => 'Nationality', 'placeholder' => 'Select Nationality', 'required' => false, 'enabled' => true, 'order' => 6, 'width' => 'half'],
164 - ['id' => 'address', 'type' => 'text', 'label' => 'Address', 'placeholder' => 'Street address (optional)', 'required' => false, 'enabled' => true, 'order' => 7, 'width' => 'full'],
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'],
165 303 ],
166 304 ],
167 305 'emergency_contact_form' => [
168 - 'title' => 'Emergency Contact',
169 - 'description' => 'Person to contact in case of emergency',
306 + 'title' => __('Emergency Contact', 'yatra'),
307 + 'description' => __('Person to contact in case of emergency', 'yatra'),
170 308 'enabled' => true,
171 309 'fields' => [
172 - ['id' => 'name', 'type' => 'text', 'label' => 'Contact Name', 'placeholder' => 'Full name', 'required' => true, 'enabled' => true, 'order' => 1, 'width' => 'half'],
173 - ['id' => 'phone', 'type' => 'tel', 'label' => 'Contact Phone', 'placeholder' => '+1 234 567 8900', 'required' => true, 'enabled' => true, 'order' => 2, 'width' => 'half'],
174 - ['id' => 'relationship', 'type' => 'select', 'label' => 'Relationship', 'placeholder' => 'Select Relationship', 'required' => false, 'enabled' => true, 'order' => 3, 'width' => 'full', 'options' => [
175 - ['value' => 'spouse', 'label' => 'Spouse/Partner'],
176 - ['value' => 'parent', 'label' => 'Parent'],
177 - ['value' => 'sibling', 'label' => 'Sibling'],
178 - ['value' => 'child', 'label' => 'Child'],
179 - ['value' => 'friend', 'label' => 'Friend'],
180 - ['value' => 'other', 'label' => 'Other'],
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')],
181 319 ]],
182 320 ],
183 321 ],
184 322 'traveler_form' => [
185 - 'title' => 'Traveler Information',
186 - 'description' => 'Please provide details for each traveler',
323 + 'title' => __('Traveler Information', 'yatra'),
324 + 'description' => __('Please provide details for each traveler', 'yatra'),
187 325 'fields' => [
188 - ['id' => 'first_name', 'type' => 'text', 'label' => 'First Name', 'placeholder' => 'Legal first name', 'required' => true, 'enabled' => true, 'order' => 1, 'width' => 'half'],
189 - ['id' => 'last_name', 'type' => 'text', 'label' => 'Last Name', 'placeholder' => 'Legal last name', 'required' => true, 'enabled' => true, 'order' => 2, 'width' => 'half'],
190 - ['id' => 'date_of_birth', 'type' => 'date', 'label' => 'Date of Birth', 'placeholder' => '', 'required' => true, 'enabled' => true, 'order' => 3, 'width' => 'half'],
191 - ['id' => 'gender', 'type' => 'select', 'label' => 'Gender', 'placeholder' => 'Select Gender', 'required' => true, 'enabled' => true, 'order' => 4, 'width' => 'half', 'options' => [
192 - ['value' => 'male', 'label' => 'Male'],
193 - ['value' => 'female', 'label' => 'Female'],
194 - ['value' => 'other', 'label' => 'Other'],
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')],
195 333 ]],
196 - ['id' => 'nationality', 'type' => 'country', 'label' => 'Nationality', 'placeholder' => 'Select Nationality', 'required' => true, 'enabled' => true, 'order' => 5, 'width' => 'full'],
197 - ['id' => 'dietary', 'type' => 'select', 'label' => 'Dietary Requirements', 'placeholder' => 'Select', 'required' => false, 'enabled' => true, 'order' => 6, 'width' => 'half', 'section' => 'dietary_medical', 'options' => [
198 - ['value' => 'none', 'label' => 'No special requirements'],
199 - ['value' => 'vegetarian', 'label' => 'Vegetarian'],
200 - ['value' => 'vegan', 'label' => 'Vegan'],
201 - ['value' => 'halal', 'label' => 'Halal'],
202 - ['value' => 'kosher', 'label' => 'Kosher'],
203 - ['value' => 'gluten_free', 'label' => 'Gluten Free'],
204 - ['value' => 'lactose_free', 'label' => 'Lactose Free'],
205 - ['value' => 'other', 'label' => 'Other (specify in notes)'],
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')],
206 344 ]],
207 - ['id' => 'medical', 'type' => 'text', 'label' => 'Medical Conditions / Allergies', 'placeholder' => 'Any allergies or conditions we should know', 'required' => false, 'enabled' => true, 'order' => 7, 'width' => 'half', 'section' => 'dietary_medical'],
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'],
208 346 ],
209 347 ],
210 348 ];
211 349 }
@@ -211,12 +349,17 @@
211 349 }
212 350
213 351 /**
214 352 * Get booking form configuration (merged with defaults)
215 - *
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).
216 359 * @return array
217 360 */
218 - public static function getBookingFormConfig(): array
361 + public static function getBookingFormConfig(?int $tripId = null): array
219 362 {
220 363 $saved_config = self::get('booking_form_config', []);
221 364 $default_config = self::getDefaultBookingFormConfig();
222 365
@@ -221,40 +364,101 @@
221 364 $default_config = self::getDefaultBookingFormConfig();
222 365
223 366 // If no saved config, return defaults (Pro may filter)
224 367 if (empty($saved_config)) {
225 - return apply_filters('yatra_booking_form_config', $default_config);
368 + return apply_filters('yatra_booking_form_config', $default_config, $tripId);
226 369 }
227 -
228 - // Build a map of locked field IDs from defaults
229 - $locked_fields = [];
230 - foreach ($default_config as $form_type => $form_config) {
231 - if (!empty($form_config['fields'])) {
232 - foreach ($form_config['fields'] as $field) {
233 - if (!empty($field['locked'])) {
234 - $locked_fields[$form_type][$field['id']] = true;
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'];
235 432 }
236 433 }
434 + $result_fields[] = $field;
237 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;
238 450 }
239 -
240 - // Merge saved with defaults
241 - $merged = array_replace_recursive($default_config, $saved_config);
242 -
243 - // Ensure locked status is preserved from defaults (locked cannot be overridden)
244 - foreach ($merged as $form_type => &$form_config) {
245 - if (!empty($form_config['fields']) && is_array($form_config['fields'])) {
246 - foreach ($form_config['fields'] as &$field) {
247 - // If this field ID is in the locked list, force locked=true and required=true
248 - if (isset($locked_fields[$form_type][$field['id']])) {
249 - $field['locked'] = true;
250 - $field['required'] = true;
251 - }
252 - }
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;
253 457 }
254 458 }
255 -
256 - return apply_filters('yatra_booking_form_config', $merged);
459 +
460 + return apply_filters('yatra_booking_form_config', $merged, $tripId);
257 461 }
258 462
259 463 private static function isEmailIdentityKey(string $key): bool
260 464 {
@@ -314,8 +518,18 @@
314 518 if (self::$settings === null) {
315 519 self::load();
316 520 }
317 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 +
318 532 // Support dot notation for nested access (future use)
319 533 if (strpos($key, '.') !== false) {
320 534 $keys = explode('.', $key);
321 535 $value = self::$settings;
@@ -379,8 +593,16 @@
379 593 if (self::isFlexiblePaymentSetting($key)) {
380 594 $value = apply_filters('yatra_flexible_payment_setting', false, $key);
381 595 return filter_var($value, FILTER_VALIDATE_BOOLEAN);
382 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 + }
383 605
384 606 $value = self::get($key, false);
385 607 return filter_var($value, FILTER_VALIDATE_BOOLEAN);
386 608 }
@@ -397,8 +619,15 @@
397 619 // Flexible payment settings require Pro module
398 620 if (self::isFlexiblePaymentSetting($key)) {
399 621 return (int) apply_filters('yatra_flexible_payment_setting', $default, $key);
400 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 + }
401 630
402 631 return (int) self::get($key, $default);
403 632 }
404 633
@@ -474,8 +703,9 @@
474 703 */
475 704 public static function reload(): void
476 705 {
477 706 self::$settings = null;
707 + self::$permalinkBasesCache = null;
478 708 self::load();
479 709 }
480 710
481 711 /**
@@ -563,14 +793,181 @@
563 793 return self::getString('currency_position', 'before');
564 794 }
565 795
566 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 + /**
567 965 * Get trip base slug
568 966 */
569 967 public static function getTripBase(): string
570 968 {
571 - $base = self::getString('trip_base', 'trip');
572 - return preg_replace('/[^a-z0-9_-]/i', '', $base) ?: 'trip';
969 + return self::getPermalinkBases()['trip_base'];
573 970 }
574 971
575 972 /**
576 973 * Get booking base slug
@@ -576,10 +973,9 @@
576 973 * Get booking base slug
577 974 */
578 975 public static function getBookingBase(): string
579 976 {
580 - $base = self::getString('booking_base', 'booking');
581 - return preg_replace('/[^a-z0-9_-]/i', '', $base) ?: 'booking';
977 + return self::getPermalinkBases()['booking_base'];
582 978 }
583 979
584 980 /**
585 981 * URL slug for the customer account area (Settings → Customer → account path).
@@ -587,22 +983,26 @@
587 983 * even when yatra_account_base was never saved or is out of sync.
588 984 */
589 985 public static function getAccountBase(): string
590 986 {
591 - $customerPath = get_option('yatra_customer_account_page', '');
592 - if (is_string($customerPath) && $customerPath !== '' && $customerPath !== '0') {
593 - $slug = self::slugFromAccountPathString($customerPath);
594 - if ($slug !== '') {
595 - return $slug;
596 - }
597 - }
987 + return self::getPermalinkBases()['account_base'];
988 + }
598 989
599 - $base = self::getString('account_base', '');
600 - $base = preg_replace('/[^a-z0-9_-]/i', '', $base) ?: '';
990 + public static function getDestinationBase(): string
991 + {
992 + return self::getPermalinkBases()['destination_base'];
993 + }
601 994
602 - return $base !== '' ? $base : 'account';
995 + public static function getActivityBase(): string
996 + {
997 + return self::getPermalinkBases()['activity_base'];
603 998 }
604 999
1000 + public static function getTripCategoryBase(): string
1001 + {
1002 + return self::getPermalinkBases()['trip_category_base'];
1003 + }
1004 +
605 1005 private static function slugFromAccountPathString(string $path): string
606 1006 {
607 1007 $path = trim(str_replace('\\', '/', $path), '/');
608 1008 $parts = array_values(array_filter(explode('/', $path), static fn ($p) => $p !== ''));
@@ -673,18 +1073,42 @@
673 1073 'deposit_percentage',
674 1074 'partial_payment',
675 1075 'partial_payment_percentage',
676 1076 'enable_deposit',
677 - 'enable_scheduled_payments',
678 - 'scheduled_payment_type',
679 - 'scheduled_payment_days',
680 - 'scheduled_payment_installments',
681 - 'scheduled_payment_interval',
682 - 'scheduled_payment_reminder_days',
683 1077 'allow_save_payment_methods',
684 1078 ];
685 1079
686 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());
687 1111 }
688 1112
689 1113 /**
690 1114 * Check if flexible payments module is available (Pro active + module enabled)