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 +258 -70 3.0.6 → 3.0.17 View file →
@@ -45,8 +45,10 @@
45 45 /** Primary brand color (hex) for trip/booking/listing frontend — see FrontendThemeCss */
46 46 'frontend_primary_color' => '#3b82f6',
47 47 /** Max width for Yatra trip/booking/listing containers (CSS length). Empty = theme.json / content width / filter. */
48 48 'frontend_container_max_width' => '',
49 + /** Trip listing card density: 'standard' | 'compact_mobile' | 'compact_all'. */
50 + 'frontend_listing_card_layout' => 'standard',
49 51
50 52 // Booking
51 53 'booking_base' => 'book',
52 54 'use_booking_page' => false,
@@ -55,8 +57,13 @@
55 57 'privacy_policy_page_id' => 0,
56 58 'enable_guest_booking' => true,
57 59 'booking_confirmation' => true,
58 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',
59 66 'require_login' => false,
60 67 'allow_guest_checkout' => true,
61 68 // Hold guest bookings in `pending_verification` status until
62 69 // the customer clicks a magic link sent to the email they
@@ -74,8 +81,12 @@
74 81 // gracefully by skipping the cancellation paragraph or
75 82 // falling back to the per-trip cancellation_policy.
76 83 'booking_expiry_hours' => 24,
77 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,
78 89 'allow_waitlist' => true,
79 90 'waitlist_auto_confirm' => false,
80 91 // Pro: when enabled, the single-trip date_specific mode renders a
81 92 // <select> of available departure dates instead of the flatpickr
@@ -112,8 +123,12 @@
112 123 'email_from_address' => '',
113 124 'admin_email' => '',
114 125 'enable_admin_notifications' => true,
115 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' => '',
116 131
117 132 // Email template enable flags.
118 133 //
119 134 // These mirror SettingsController::$default_settings + the
@@ -131,8 +146,13 @@
131 146 // confirmation + every transactional email silently no-ops
132 147 // on those installs (sendIfEnabled gates on the flag).
133 148 'email_template_booking' => true,
134 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,
135 155 'email_template_cancellation' => true,
136 156 'email_template_reminder' => true,
137 157 'email_template_admin_new_booking' => true,
138 158 'email_template_admin_payment' => true,
@@ -139,8 +159,10 @@
139 159 'email_template_admin_cancellation' => true,
140 160 'email_template_trip_consent' => true,
141 161 'email_template_customer_verification' => true,
142 162 'email_template_guest_verification' => true,
163 + 'email_template_account_email_change' => true,
164 + 'email_template_account_email_changed' => true,
143 165 'email_template_booking_completed' => true,
144 166 'email_template_booking_expired_customer' => true,
145 167 'email_template_admin_booking_expired' => true,
146 168 'email_template_scheduled_payment_reminder' => true,
@@ -149,9 +171,18 @@
149 171 'email_template_admin_scheduled_payment_failed' => true,
150 172 'email_template_enquiry_received' => true,
151 173 'email_template_enquiry_admin' => true,
152 174 'email_template_enquiry_response' => true,
153 - 'email_template_review_request' => 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,
154 185 'email_template_abandoned_booking_recovery_first' => true,
155 186 'email_template_abandoned_booking_recovery_second' => true,
156 187 'email_template_abandoned_booking_recovery_final' => true,
157 188 // Customer-registration gate (AuthController::register reads
@@ -166,9 +197,27 @@
166 197 'trips_per_page' => 12,
167 198 'enable_wishlist' => false,
168 199 'enable_comparison' => false,
169 200 'show_sold_out' => true,
170 -
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 +
171 220 // Customer
172 221 'enable_customer_accounts' => true,
173 222 'enable_customer_registration' => true,
174 223 'customer_account_page' => 0,
@@ -178,9 +227,13 @@
178 227 'require_booking_to_review' => false,
179 228 'auto_approve_reviews' => false,
180 229 'enable_review_moderation' => true,
181 230 'minimum_rating' => 1,
182 - '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,
183 236
184 237 // Tax
185 238 'enable_tax' => false,
186 239 'tax_rate' => 0,
@@ -202,8 +255,18 @@
202 255 'destination_base' => 'destination',
203 256 'activity_base' => 'activity',
204 257 'trip_category_base' => 'trip-category',
205 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 +
206 269 // Advanced
207 270 'enable_debug_mode' => false,
208 271 'delete_data_on_uninstall' => false,
209 272
@@ -217,63 +280,70 @@
217 280 * @return array
218 281 */
219 282 public static function getDefaultBookingFormConfig(): array
220 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()).
221 291 return [
222 292 'contact_form' => [
223 - 'title' => 'Lead Traveler / Contact Information',
224 - 'description' => 'Primary contact person for this booking',
293 + 'title' => __('Lead Traveler / Contact Information', 'yatra'),
294 + 'description' => __('Primary contact person for this booking', 'yatra'),
225 295 'fields' => [
226 - ['id' => 'first_name', 'type' => 'text', 'label' => 'First Name', 'placeholder' => 'Enter first name', 'required' => true, 'enabled' => true, 'order' => 1, 'width' => 'half', 'locked' => true],
227 - ['id' => 'last_name', 'type' => 'text', 'label' => 'Last Name', 'placeholder' => 'Enter last name', 'required' => true, 'enabled' => true, 'order' => 2, 'width' => 'half', 'locked' => true],
228 - ['id' => 'email', 'type' => 'email', 'label' => 'Email Address', 'placeholder' => '[email protected]', 'required' => true, 'enabled' => true, 'order' => 3, 'width' => 'half', 'locked' => true],
229 - ['id' => 'phone', 'type' => 'tel', 'label' => 'Phone Number', 'placeholder' => '+1 234 567 8900', 'required' => true, 'enabled' => true, 'order' => 4, 'width' => 'half', 'locked' => true],
230 - ['id' => 'country', 'type' => 'country', 'label' => 'Country', 'placeholder' => 'Select Country', 'required' => true, 'enabled' => true, 'order' => 5, 'width' => 'half', 'locked' => true],
231 - ['id' => 'nationality', 'type' => 'country', 'label' => 'Nationality', 'placeholder' => 'Select Nationality', 'required' => false, 'enabled' => true, 'order' => 6, 'width' => 'half'],
232 - ['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'],
233 303 ],
234 304 ],
235 305 'emergency_contact_form' => [
236 - 'title' => 'Emergency Contact',
237 - 'description' => 'Person to contact in case of emergency',
306 + 'title' => __('Emergency Contact', 'yatra'),
307 + 'description' => __('Person to contact in case of emergency', 'yatra'),
238 308 'enabled' => true,
239 309 'fields' => [
240 - ['id' => 'name', 'type' => 'text', 'label' => 'Contact Name', 'placeholder' => 'Full name', 'required' => true, 'enabled' => true, 'order' => 1, 'width' => 'half'],
241 - ['id' => 'phone', 'type' => 'tel', 'label' => 'Contact Phone', 'placeholder' => '+1 234 567 8900', 'required' => true, 'enabled' => true, 'order' => 2, 'width' => 'half'],
242 - ['id' => 'relationship', 'type' => 'select', 'label' => 'Relationship', 'placeholder' => 'Select Relationship', 'required' => false, 'enabled' => true, 'order' => 3, 'width' => 'full', 'options' => [
243 - ['value' => 'spouse', 'label' => 'Spouse/Partner'],
244 - ['value' => 'parent', 'label' => 'Parent'],
245 - ['value' => 'sibling', 'label' => 'Sibling'],
246 - ['value' => 'child', 'label' => 'Child'],
247 - ['value' => 'friend', 'label' => 'Friend'],
248 - ['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')],
249 319 ]],
250 320 ],
251 321 ],
252 322 'traveler_form' => [
253 - 'title' => 'Traveler Information',
254 - 'description' => 'Please provide details for each traveler',
323 + 'title' => __('Traveler Information', 'yatra'),
324 + 'description' => __('Please provide details for each traveler', 'yatra'),
255 325 'fields' => [
256 - ['id' => 'first_name', 'type' => 'text', 'label' => 'First Name', 'placeholder' => 'Legal first name', 'required' => true, 'enabled' => true, 'order' => 1, 'width' => 'half'],
257 - ['id' => 'last_name', 'type' => 'text', 'label' => 'Last Name', 'placeholder' => 'Legal last name', 'required' => true, 'enabled' => true, 'order' => 2, 'width' => 'half'],
258 - ['id' => 'date_of_birth', 'type' => 'date', 'label' => 'Date of Birth', 'placeholder' => '', 'required' => true, 'enabled' => true, 'order' => 3, 'width' => 'half'],
259 - ['id' => 'gender', 'type' => 'select', 'label' => 'Gender', 'placeholder' => 'Select Gender', 'required' => true, 'enabled' => true, 'order' => 4, 'width' => 'half', 'options' => [
260 - ['value' => 'male', 'label' => 'Male'],
261 - ['value' => 'female', 'label' => 'Female'],
262 - ['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')],
263 333 ]],
264 - ['id' => 'nationality', 'type' => 'country', 'label' => 'Nationality', 'placeholder' => 'Select Nationality', 'required' => true, 'enabled' => true, 'order' => 5, 'width' => 'full'],
265 - ['id' => 'dietary', 'type' => 'select', 'label' => 'Dietary Requirements', 'placeholder' => 'Select', 'required' => false, 'enabled' => true, 'order' => 6, 'width' => 'half', 'section' => 'dietary_medical', 'options' => [
266 - ['value' => 'none', 'label' => 'No special requirements'],
267 - ['value' => 'vegetarian', 'label' => 'Vegetarian'],
268 - ['value' => 'vegan', 'label' => 'Vegan'],
269 - ['value' => 'halal', 'label' => 'Halal'],
270 - ['value' => 'kosher', 'label' => 'Kosher'],
271 - ['value' => 'gluten_free', 'label' => 'Gluten Free'],
272 - ['value' => 'lactose_free', 'label' => 'Lactose Free'],
273 - ['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')],
274 344 ]],
275 - ['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'],
276 346 ],
277 347 ],
278 348 ];
279 349 }
@@ -279,12 +349,17 @@
279 349 }
280 350
281 351 /**
282 352 * Get booking form configuration (merged with defaults)
283 - *
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).
284 359 * @return array
285 360 */
286 - public static function getBookingFormConfig(): array
361 + public static function getBookingFormConfig(?int $tripId = null): array
287 362 {
288 363 $saved_config = self::get('booking_form_config', []);
289 364 $default_config = self::getDefaultBookingFormConfig();
290 365
@@ -289,40 +364,101 @@
289 364 $default_config = self::getDefaultBookingFormConfig();
290 365
291 366 // If no saved config, return defaults (Pro may filter)
292 367 if (empty($saved_config)) {
293 - return apply_filters('yatra_booking_form_config', $default_config);
368 + return apply_filters('yatra_booking_form_config', $default_config, $tripId);
294 369 }
295 -
296 - // Build a map of locked field IDs from defaults
297 - $locked_fields = [];
298 - foreach ($default_config as $form_type => $form_config) {
299 - if (!empty($form_config['fields'])) {
300 - foreach ($form_config['fields'] as $field) {
301 - if (!empty($field['locked'])) {
302 - $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'];
303 432 }
304 433 }
434 + $result_fields[] = $field;
305 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;
306 450 }
307 -
308 - // Merge saved with defaults
309 - $merged = array_replace_recursive($default_config, $saved_config);
310 -
311 - // Ensure locked status is preserved from defaults (locked cannot be overridden)
312 - foreach ($merged as $form_type => &$form_config) {
313 - if (!empty($form_config['fields']) && is_array($form_config['fields'])) {
314 - foreach ($form_config['fields'] as &$field) {
315 - // If this field ID is in the locked list, force locked=true and required=true
316 - if (isset($locked_fields[$form_type][$field['id']])) {
317 - $field['locked'] = true;
318 - $field['required'] = true;
319 - }
320 - }
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;
321 457 }
322 458 }
323 -
324 - return apply_filters('yatra_booking_form_config', $merged);
459 +
460 + return apply_filters('yatra_booking_form_config', $merged, $tripId);
325 461 }
326 462
327 463 private static function isEmailIdentityKey(string $key): bool
328 464 {
@@ -657,8 +793,52 @@
657 793 return self::getString('currency_position', 'before');
658 794 }
659 795
660 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 + /**
661 841 * Sanitize a single URL path segment used in Yatra rewrites (alphanumeric, underscore, hyphen).
662 842 */
663 843 private static function sanitizePermalinkSlug(string $value, string $fallback): string
664 844 {
@@ -913,8 +1093,16 @@
913 1093 'scheduled_payment_days' => 15,
914 1094 'scheduled_payment_installments' => 1,
915 1095 'scheduled_payment_interval' => 30,
916 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,
917 1105 ];
918 1106 }
919 1107
920 1108 private static function isScheduledPaymentSetting(string $key): bool