| 1 |
<?php |
| 2 |
|
| 3 |
declare(strict_types=1); |
| 4 |
|
| 5 |
namespace Yatra\Controllers; |
| 6 |
|
| 7 |
use WP_REST_Request; |
| 8 |
use WP_REST_Response; |
| 9 |
use WP_Error; |
| 10 |
use Yatra\Services\EmailTemplatePreviewService; |
| 11 |
|
| 12 |
/** |
| 13 |
* Settings REST API Controller |
| 14 |
* Handles getting and updating plugin settings stored in WordPress options table |
| 15 |
*/ |
| 16 |
class SettingsController extends BaseController |
| 17 |
{ |
| 18 |
/** |
| 19 |
* All settings fields with their default values |
| 20 |
* Pro plugin can add additional settings via filter |
| 21 |
*/ |
| 22 |
private array $default_settings; |
| 23 |
|
| 24 |
/** |
| 25 |
* Constructor - initialize default settings with filter |
| 26 |
*/ |
| 27 |
public function __construct() |
| 28 |
{ |
| 29 |
$wpAdminEmail = (string) get_option('admin_email', ''); |
| 30 |
$wpSiteName = (string) get_bloginfo('name'); |
| 31 |
|
| 32 |
// Define base settings |
| 33 |
$base_settings = [ |
| 34 |
// General Settings |
| 35 |
'company_name' => '', |
| 36 |
'company_email' => '', |
| 37 |
'company_phone' => '', |
| 38 |
'company_address' => '', |
| 39 |
'company_city' => '', |
| 40 |
'company_state' => '', |
| 41 |
'company_country' => '', |
| 42 |
'company_zip' => '', |
| 43 |
'company_website' => '', |
| 44 |
'company_logo' => '', |
| 45 |
'timezone' => 'UTC', |
| 46 |
'date_format' => 'Y-m-d', |
| 47 |
'time_format' => 'H:i', |
| 48 |
'frontend_primary_color' => '#3b82f6', |
| 49 |
'frontend_container_max_width' => '', |
| 50 |
|
| 51 |
// Booking Settings |
| 52 |
'booking_confirmation' => true, |
| 53 |
'auto_confirm_bookings' => false, |
| 54 |
'auto_confirm_pay_later' => true, |
| 55 |
'require_login' => false, |
| 56 |
'allow_guest_checkout' => true, |
| 57 |
// cancellation_policy / cancellation_days / refund_policy were |
| 58 |
// removed in 3.0.5 — they only inserted text into the booking |
| 59 |
// confirmation email but did NOT enforce a cancellation cutoff |
| 60 |
// because Yatra has no customer-facing self-service |
| 61 |
// cancellation flow. Per-trip cancellation copy on the Trip |
| 62 |
// editor is the supported way to communicate policy. If those |
| 63 |
// legacy options still exist in wp_options on upgraded sites |
| 64 |
// they're harmless orphans — the save endpoint no longer |
| 65 |
// accepts them, and the email template skips the cancellation |
| 66 |
// paragraph when the global setting is absent. |
| 67 |
'booking_expiry_hours' => 24, |
| 68 |
'booking_reminder_days' => 3, |
| 69 |
'allow_waitlist' => true, |
| 70 |
'waitlist_auto_confirm' => false, |
| 71 |
// Pro: render available departure dates as a <select> instead of a |
| 72 |
// flatpickr calendar on the single-trip sidebar (desktop + mobile). |
| 73 |
'date_picker_as_dropdown' => false, |
| 74 |
|
| 75 |
// Payment Settings |
| 76 |
'currency' => 'USD', |
| 77 |
'payment_test_mode' => true, |
| 78 |
'payment_gateways' => [], |
| 79 |
'payment_methods' => [], |
| 80 |
'partial_payment' => false, |
| 81 |
'partial_payment_percentage' => 30, |
| 82 |
'deposit_required' => false, |
| 83 |
'deposit_percentage' => 20, |
| 84 |
'gateway_configs' => [], |
| 85 |
'gateway_order' => [], |
| 86 |
|
| 87 |
// Discount Stacking Mode — controls how the Advanced Discount and |
| 88 |
// Dynamic Pricing modules combine when both can fire on the same |
| 89 |
// booking. Default 'both' preserves the legacy stacked behavior |
| 90 |
// (discount on top of DP-adjusted price). The Settings → Pricing |
| 91 |
// tab only surfaces this setting when BOTH modules are enabled, |
| 92 |
// and CalculationService only enforces a non-default mode when |
| 93 |
// BOTH modules are loaded — so sites with only one (or neither) |
| 94 |
// module see zero behavior change. |
| 95 |
// |
| 96 |
// Allowed: 'both' | 'discount_only' | 'dynamic_pricing_only' | 'best_for_customer' |
| 97 |
'discount_stacking_mode' => 'both', |
| 98 |
|
| 99 |
// Scheduled/Recurring Payment Settings (Pro feature - defaults disabled) |
| 100 |
'enable_scheduled_payments' => false, |
| 101 |
'scheduled_payment_type' => 'single', // single, installments |
| 102 |
'scheduled_payment_days' => 15, // Days until first scheduled payment |
| 103 |
'scheduled_payment_installments' => 1, // Number of installments (if type is installments) |
| 104 |
'scheduled_payment_interval' => 30, // Days between installments |
| 105 |
'scheduled_payment_reminder_days' => 3, // Days before to send reminder |
| 106 |
'balance_anchor' => 'booking', // 'booking' (BC default) | 'tour' (relative to tour date) |
| 107 |
'balance_due_days' => 14, // When anchor=tour: balance due this many days before the tour |
| 108 |
'allow_save_payment_methods' => false, |
| 109 |
|
| 110 |
// Email Settings (WordPress site defaults when Yatra options are missing) |
| 111 |
'admin_email' => $wpAdminEmail, |
| 112 |
'from_email' => $wpAdminEmail, |
| 113 |
'from_name' => $wpSiteName, |
| 114 |
// Blind copy of every outgoing Yatra email, for archiving/monitoring. |
| 115 |
// Empty means no copy is sent; accepts several comma-separated addresses. |
| 116 |
'email_always_bcc' => '', |
| 117 |
'email_template_booking' => true, |
| 118 |
'email_template_confirmation' => true, |
| 119 |
// Separate part-payment email. Off by default so existing sites keep |
| 120 |
// sending the single payment template for every payment. |
| 121 |
'email_template_partial_payment' => false, |
| 122 |
'email_template_cancellation' => true, |
| 123 |
'email_template_reminder' => true, |
| 124 |
'email_template_admin_new_booking' => true, |
| 125 |
'email_template_admin_payment' => true, |
| 126 |
'email_template_admin_cancellation' => true, |
| 127 |
'email_template_trip_consent' => true, |
| 128 |
'email_template_customer_verification' => true, |
| 129 |
'email_template_guest_verification' => true, |
| 130 |
'email_template_account_email_change' => true, |
| 131 |
'email_template_account_email_changed' => true, |
| 132 |
'email_template_booking_completed' => true, |
| 133 |
'email_template_booking_expired_customer' => true, |
| 134 |
'email_template_admin_booking_expired' => true, |
| 135 |
'email_template_scheduled_payment_reminder' => true, |
| 136 |
'email_template_scheduled_payment_succeeded' => true, |
| 137 |
'email_template_scheduled_payment_failed' => true, |
| 138 |
'email_template_admin_scheduled_payment_failed' => true, |
| 139 |
'email_template_enquiry_received' => true, |
| 140 |
'email_template_enquiry_admin' => true, |
| 141 |
'email_template_enquiry_response' => true, |
| 142 |
'email_template_review_request' => true, |
| 143 |
'email_template_abandoned_booking_recovery_first' => true, |
| 144 |
'email_template_abandoned_booking_recovery_second' => true, |
| 145 |
'email_template_abandoned_booking_recovery_final' => true, |
| 146 |
'smtp_enabled' => false, |
| 147 |
'smtp_host' => 'smtp.gmail.com', |
| 148 |
'smtp_port' => 587, |
| 149 |
'smtp_username' => '', |
| 150 |
'smtp_password' => '', |
| 151 |
'smtp_encryption' => 'tls', |
| 152 |
|
| 153 |
// Customer Settings |
| 154 |
'customer_registration' => true, |
| 155 |
'customer_fields' => [], |
| 156 |
'require_email_verification' => false, |
| 157 |
// Per-booking verification for guest checkouts. Distinct from the |
| 158 |
// account-creation `require_email_verification` flag because a guest |
| 159 |
// never registers — the verification is gated on the booking itself |
| 160 |
// (BookingSessionController checks this when admitting a guest). |
| 161 |
'require_guest_email_verification' => false, |
| 162 |
'customer_account_page' => '', |
| 163 |
'allow_customer_reviews' => true, |
| 164 |
'customer_dashboard_enabled' => true, |
| 165 |
|
| 166 |
// Review Settings |
| 167 |
'enable_reviews' => true, |
| 168 |
'require_booking' => true, |
| 169 |
'auto_approve_reviews' => false, |
| 170 |
'review_moderation' => true, |
| 171 |
'min_rating' => 1, |
| 172 |
'allow_anonymous_reviews' => false, |
| 173 |
'review_reminder_days' => 7, |
| 174 |
|
| 175 |
// Tax Settings |
| 176 |
'enable_tax' => false, |
| 177 |
'tax_name' => __('Tax', 'yatra'), |
| 178 |
'tax_rate' => 0, |
| 179 |
'tax_inclusive' => false, |
| 180 |
'vat_number' => '', |
| 181 |
'tax_by_country' => false, |
| 182 |
'tax_rates' => [], |
| 183 |
'multiple_taxes_enabled' => false, |
| 184 |
'multiple_taxes' => [], |
| 185 |
'multiple_taxes_by_country' => [], |
| 186 |
|
| 187 |
// Currency Settings |
| 188 |
'default_currency' => 'USD', |
| 189 |
'multi_currency' => false, |
| 190 |
'currency_position' => 'left', |
| 191 |
'currency_decimals' => 2, |
| 192 |
'decimal_separator'=>'.', |
| 193 |
'thousand_separator'=>',', |
| 194 |
|
| 195 |
// Notification Settings (SMS / future channels — booking email toggles live under Email → Templates) |
| 196 |
'sms_notifications' => false, |
| 197 |
'sms_provider' => '', |
| 198 |
'sms_api_key' => '', |
| 199 |
|
| 200 |
// Integration Settings |
| 201 |
'google_analytics' => '', |
| 202 |
'facebook_pixel' => '', |
| 203 |
'recaptcha_enabled' => false, |
| 204 |
'recaptcha_site_key' => '', |
| 205 |
'recaptcha_secret_key' => '', |
| 206 |
// reCAPTCHA v3: score threshold (0.0-1.0) + per-form protection toggles. |
| 207 |
// All off by default so enabling reCAPTCHA alone changes nothing until |
| 208 |
// the operator picks which forms to protect. |
| 209 |
'recaptcha_score_threshold' => 0.5, |
| 210 |
'recaptcha_protect_enquiry' => false, |
| 211 |
'recaptcha_protect_booking' => false, |
| 212 |
'recaptcha_protect_registration' => false, |
| 213 |
|
| 214 |
// Permalink Settings |
| 215 |
'trip_base' => 'trip', |
| 216 |
'destination_base' => 'destination', |
| 217 |
'activity_base' => 'activity', |
| 218 |
'trip_category_base' => 'trip-category', |
| 219 |
'booking_base' => 'book', |
| 220 |
// Wishlist (Pro) — stored in free options; active only when Pro + setting on |
| 221 |
'enable_wishlist' => false, |
| 222 |
// Sold-out date visibility on the storefront. Default true keeps the |
| 223 |
// existing behaviour (sold-out dates stay visible, badged "sold out" and |
| 224 |
// able to drive the waitlist); owners can switch it off to hide them the |
| 225 |
// same way blocked dates are hidden. |
| 226 |
'show_sold_out' => true, |
| 227 |
|
| 228 |
// Search & Listing storefront UX. Defaults preserve current behaviour: |
| 229 |
// every search field shown (true) and mobile filters expanded (false), |
| 230 |
// so existing installs are unchanged until the owner opts in. Booleans |
| 231 |
// are auto-sanitized from the default type. |
| 232 |
'search_show_keyword' => true, |
| 233 |
'search_show_destination' => true, |
| 234 |
'search_show_activities' => true, |
| 235 |
'search_show_duration' => true, |
| 236 |
'search_show_budget' => true, |
| 237 |
// Date field is opt-in (default false) so updating the plugin never |
| 238 |
// changes an existing site's search bar. Operators enable it to let |
| 239 |
// customers find trips with a departure on a specific date. |
| 240 |
'search_show_date' => false, |
| 241 |
'collapse_filters_on_mobile' => false, |
| 242 |
|
| 243 |
// Booking Page Settings |
| 244 |
'use_booking_page' => false, |
| 245 |
'booking_page_id' => 0, |
| 246 |
|
| 247 |
// Legal Pages (Booking UI) |
| 248 |
'terms_page_id' => 0, |
| 249 |
'privacy_policy_page_id' => 0, |
| 250 |
|
| 251 |
// SEO Settings |
| 252 |
'seo_trip_meta_title' => '', |
| 253 |
'seo_trip_meta_description' => '', |
| 254 |
'seo_trip_meta_keywords' => '', |
| 255 |
'seo_trip_meta_image' => 0, |
| 256 |
'enable_sitemap' => true, |
| 257 |
|
| 258 |
// Advanced Settings |
| 259 |
'debug_mode' => false, |
| 260 |
'enable_logging' => false, |
| 261 |
'cache_enabled' => true, |
| 262 |
'api_key' => '', |
| 263 |
'api_rate_limit' => 100, |
| 264 |
'session_timeout' => 3600, |
| 265 |
|
| 266 |
// Booking Form Builder |
| 267 |
'booking_form_config' => [], |
| 268 |
]; |
| 269 |
|
| 270 |
$base_settings = array_merge( |
| 271 |
$base_settings, |
| 272 |
\Yatra\Services\EmailTemplateDefaults::settingsOptionDefaults() |
| 273 |
); |
| 274 |
|
| 275 |
// Allow Pro plugins to add their settings via filter |
| 276 |
$this->default_settings = apply_filters('yatra_settings_default_fields', $base_settings); |
| 277 |
} |
| 278 |
|
| 279 |
public function register_routes(): void |
| 280 |
{ |
| 281 |
$namespace = 'yatra/v1'; |
| 282 |
$base = 'settings'; |
| 283 |
|
| 284 |
register_rest_route($namespace, '/' . $base, [ |
| 285 |
[ |
| 286 |
'methods' => \WP_REST_Server::READABLE, |
| 287 |
'callback' => [$this, 'get_settings'], |
| 288 |
'permission_callback' => [$this, 'check_permission'], |
| 289 |
], |
| 290 |
[ |
| 291 |
'methods' => \WP_REST_Server::EDITABLE, |
| 292 |
'callback' => [$this, 'update_settings'], |
| 293 |
'permission_callback' => [$this, 'check_permission'], |
| 294 |
], |
| 295 |
]); |
| 296 |
|
| 297 |
// Flush rewrite rules endpoint |
| 298 |
register_rest_route($namespace, '/' . $base . '/flush-rewrite-rules', [ |
| 299 |
[ |
| 300 |
'methods' => \WP_REST_Server::CREATABLE, |
| 301 |
'callback' => [$this, 'flush_rewrite_rules'], |
| 302 |
'permission_callback' => [$this, 'check_permission'], |
| 303 |
], |
| 304 |
]); |
| 305 |
|
| 306 |
// Get WordPress pages for booking page selection |
| 307 |
register_rest_route($namespace, '/' . $base . '/pages', [ |
| 308 |
[ |
| 309 |
'methods' => \WP_REST_Server::READABLE, |
| 310 |
'callback' => [$this, 'get_pages'], |
| 311 |
'permission_callback' => [$this, 'check_permission'], |
| 312 |
], |
| 313 |
]); |
| 314 |
|
| 315 |
// Check if page has booking shortcode |
| 316 |
register_rest_route($namespace, '/' . $base . '/check-shortcode/(?P<page_id>\d+)', [ |
| 317 |
[ |
| 318 |
'methods' => \WP_REST_Server::READABLE, |
| 319 |
'callback' => [$this, 'check_booking_shortcode'], |
| 320 |
'permission_callback' => [$this, 'check_permission'], |
| 321 |
], |
| 322 |
]); |
| 323 |
|
| 324 |
// Insert booking shortcode into page |
| 325 |
register_rest_route($namespace, '/' . $base . '/insert-shortcode/(?P<page_id>\d+)', [ |
| 326 |
[ |
| 327 |
'methods' => \WP_REST_Server::CREATABLE, |
| 328 |
'callback' => [$this, 'insert_booking_shortcode'], |
| 329 |
'permission_callback' => [$this, 'check_permission'], |
| 330 |
], |
| 331 |
]); |
| 332 |
|
| 333 |
register_rest_route($namespace, '/' . $base . '/email-template-preview', [ |
| 334 |
[ |
| 335 |
'methods' => \WP_REST_Server::CREATABLE, |
| 336 |
'callback' => [$this, 'preview_core_email_template'], |
| 337 |
'permission_callback' => [$this, 'check_permission'], |
| 338 |
], |
| 339 |
]); |
| 340 |
} |
| 341 |
|
| 342 |
/** |
| 343 |
* Preview a core (settings-backed) transactional template with sample merge data. |
| 344 |
*/ |
| 345 |
public function preview_core_email_template(WP_REST_Request $request) |
| 346 |
{ |
| 347 |
try { |
| 348 |
$params = $request->get_json_params(); |
| 349 |
if (!is_array($params)) { |
| 350 |
return $this->error_response(__('Invalid request body.', 'yatra'), 400); |
| 351 |
} |
| 352 |
|
| 353 |
$templateKey = sanitize_key($params['template_key'] ?? ''); |
| 354 |
$subjectTpl = sanitize_text_field($params['subject'] ?? ''); |
| 355 |
$bodyTpl = wp_kses_post($params['body'] ?? ''); |
| 356 |
$tripId = isset($params['trip_id']) ? (int) $params['trip_id'] : 0; |
| 357 |
$tripId = $tripId > 0 ? $tripId : null; |
| 358 |
|
| 359 |
$rendered = EmailTemplatePreviewService::render($templateKey, $subjectTpl, $bodyTpl, $tripId); |
| 360 |
|
| 361 |
return $this->success_response([ |
| 362 |
'success' => true, |
| 363 |
'data' => [ |
| 364 |
'subject' => $rendered['subject'], |
| 365 |
'body' => $rendered['body'], |
| 366 |
], |
| 367 |
]); |
| 368 |
} catch (\InvalidArgumentException $e) { |
| 369 |
return $this->error_response($e->getMessage(), 400); |
| 370 |
} catch (\Exception $e) { |
| 371 |
return $this->error_response($e->getMessage(), 500); |
| 372 |
} |
| 373 |
} |
| 374 |
|
| 375 |
/** |
| 376 |
* Plugin settings — high-sensitivity cap. By default only the |
| 377 |
* Owner role holds `yatra_manage_settings` (Manager doesn't, by |
| 378 |
* design — settings include payment gateway routing, email |
| 379 |
* delivery configuration, currency formatting and similar |
| 380 |
* global behaviour). WP admins pass via the Team module's |
| 381 |
* admin-fallback filter. |
| 382 |
*/ |
| 383 |
public function check_permission(?WP_REST_Request $request = null): bool |
| 384 |
{ |
| 385 |
if (!is_user_logged_in()) { |
| 386 |
return false; |
| 387 |
} |
| 388 |
return current_user_can('yatra_manage_settings'); |
| 389 |
} |
| 390 |
|
| 391 |
/** |
| 392 |
* Get all settings |
| 393 |
*/ |
| 394 |
public function get_settings(WP_REST_Request $request) |
| 395 |
{ |
| 396 |
try { |
| 397 |
$settings = []; |
| 398 |
|
| 399 |
// Get all settings from WordPress options table with yatra_ prefix. |
| 400 |
// A sentinel default is essential here: get_option() returns boolean |
| 401 |
// false for a stored-false option just as it does for a missing one, |
| 402 |
// so checking `=== false` would reset every saved-off boolean back to |
| 403 |
// its default. That is exactly the "Show sold-out dates" bug — the |
| 404 |
// storefront honoured the saved value (isEnabled coerces '' -> false) |
| 405 |
// while the admin checkbox re-appeared enabled because this endpoint |
| 406 |
// handed React the default (true) instead of the saved false. |
| 407 |
$unset_sentinel = "\0__yatra_option_unset__\0"; |
| 408 |
foreach ($this->default_settings as $key => $default_value) { |
| 409 |
$option_name = 'yatra_' . $key; |
| 410 |
$value = get_option($option_name, $unset_sentinel); |
| 411 |
|
| 412 |
// Only use default when the option truly does not exist. |
| 413 |
if ($value === $unset_sentinel) { |
| 414 |
$value = $default_value; |
| 415 |
} |
| 416 |
|
| 417 |
// Stored empty string should behave like "unset" for delivery identity (matches installer / backfill). |
| 418 |
if (($key === 'admin_email' || $key === 'from_email') && is_string($value) && trim($value) === '') { |
| 419 |
$wp = (string) get_option('admin_email', ''); |
| 420 |
$value = $wp !== '' ? $wp : $value; |
| 421 |
} |
| 422 |
if ($key === 'from_name' && is_string($value) && trim($value) === '') { |
| 423 |
$wp = (string) get_bloginfo('name'); |
| 424 |
$value = $wp !== '' ? $wp : $value; |
| 425 |
} |
| 426 |
|
| 427 |
// Handle serialized arrays (for fields like payment_gateways, customer_fields, etc.) |
| 428 |
if (is_string($value) && is_serialized($value)) { |
| 429 |
$value = maybe_unserialize($value); |
| 430 |
} |
| 431 |
|
| 432 |
// Ensure arrays are returned as arrays (not objects) |
| 433 |
if (is_array($default_value) && !is_array($value)) { |
| 434 |
$value = []; |
| 435 |
} |
| 436 |
|
| 437 |
// Boolean settings must round-trip to the admin as real booleans. |
| 438 |
// update_option() stores false as '' and the object cache can |
| 439 |
// return boolean false, so without this a disabled toggle would |
| 440 |
// reach React as '' / false and the checkbox (checked unless the |
| 441 |
// value is strictly !== false) would render enabled again. |
| 442 |
if (is_bool($default_value)) { |
| 443 |
$value = filter_var($value, FILTER_VALIDATE_BOOLEAN); |
| 444 |
} |
| 445 |
|
| 446 |
$settings[$key] = $value; |
| 447 |
} |
| 448 |
|
| 449 |
// Special handling for booking_form_config - always use getBookingFormConfig which handles locked fields |
| 450 |
$settings['booking_form_config'] = \Yatra\Services\SettingsService::getBookingFormConfig(); |
| 451 |
|
| 452 |
// Merge in flexible payment settings from Pro module if enabled |
| 453 |
$flexible_payment_settings = apply_filters('yatra_get_flexible_payment_settings', []); |
| 454 |
if (!empty($flexible_payment_settings)) { |
| 455 |
$settings = array_merge($settings, $flexible_payment_settings); |
| 456 |
} |
| 457 |
|
| 458 |
$scheduled_payment_settings = apply_filters('yatra_get_scheduled_payment_settings', []); |
| 459 |
if (!empty($scheduled_payment_settings)) { |
| 460 |
$settings = array_merge($settings, $scheduled_payment_settings); |
| 461 |
} |
| 462 |
|
| 463 |
// Scheduled payment keys are owned by Pro (yatra_pro_scheduled_payments), not yatra_* options. |
| 464 |
foreach ( |
| 465 |
[ |
| 466 |
'enable_scheduled_payments', |
| 467 |
'scheduled_payment_type', |
| 468 |
'scheduled_payment_days', |
| 469 |
'scheduled_payment_installments', |
| 470 |
'scheduled_payment_interval', |
| 471 |
'scheduled_payment_reminder_days', |
| 472 |
'balance_anchor', |
| 473 |
'balance_due_days', |
| 474 |
] as $sk |
| 475 |
) { |
| 476 |
if (array_key_exists($sk, $this->default_settings)) { |
| 477 |
$settings[$sk] = \Yatra\Services\SettingsService::get( |
| 478 |
$sk, |
| 479 |
$this->default_settings[$sk] |
| 480 |
); |
| 481 |
} |
| 482 |
} |
| 483 |
|
| 484 |
$settings = $this->syncAccountRouteSettingsForResponse($settings); |
| 485 |
|
| 486 |
/** |
| 487 |
* Allow Pro modules to align REST payloads with canonical option stores |
| 488 |
* (e.g. GA4 settings that also live in yatra_google_analytics_settings). |
| 489 |
*/ |
| 490 |
$settings = apply_filters('yatra_rest_settings', $settings); |
| 491 |
|
| 492 |
return $this->success_response($settings); |
| 493 |
} catch (\Exception $e) { |
| 494 |
return $this->error_response($e->getMessage(), 500); |
| 495 |
} |
| 496 |
} |
| 497 |
|
| 498 |
/** |
| 499 |
* Update settings |
| 500 |
*/ |
| 501 |
public function update_settings(WP_REST_Request $request) |
| 502 |
{ |
| 503 |
try { |
| 504 |
$data = $request->get_json_params(); |
| 505 |
|
| 506 |
if (!is_array($data)) { |
| 507 |
return $this->error_response('Invalid settings data', 400); |
| 508 |
} |
| 509 |
|
| 510 |
$updated = []; |
| 511 |
$errors = []; |
| 512 |
|
| 513 |
// Check if Dynamic Form Field module is enabled |
| 514 |
$is_dynamic_form_enabled = apply_filters('yatra_dynamic_form_field_enabled', false); |
| 515 |
|
| 516 |
// Check if Flexible Payments module is enabled (Pro feature) |
| 517 |
$is_flexible_payments_enabled = apply_filters('yatra_flexible_payments_enabled', false); |
| 518 |
|
| 519 |
$is_scheduled_payments_module = apply_filters('yatra_scheduled_payments_module_active', false); |
| 520 |
|
| 521 |
// Flexible payment settings keys (Pro only) |
| 522 |
$flexible_payment_keys = [ |
| 523 |
'deposit_required', 'deposit_percentage', 'partial_payment', |
| 524 |
'partial_payment_percentage', 'enable_deposit', 'allow_save_payment_methods', |
| 525 |
]; |
| 526 |
|
| 527 |
$scheduled_payment_keys = [ |
| 528 |
'enable_scheduled_payments', |
| 529 |
'scheduled_payment_type', |
| 530 |
'scheduled_payment_days', |
| 531 |
'scheduled_payment_installments', |
| 532 |
'scheduled_payment_interval', |
| 533 |
'scheduled_payment_reminder_days', |
| 534 |
'balance_anchor', |
| 535 |
'balance_due_days', |
| 536 |
]; |
| 537 |
|
| 538 |
// Collect flexible payment settings to delegate to Pro |
| 539 |
$flexible_payment_settings = []; |
| 540 |
|
| 541 |
$scheduled_payment_settings_batch = []; |
| 542 |
|
| 543 |
// Process each setting |
| 544 |
foreach ($data as $key => $value) { |
| 545 |
// Skip booking_form_config if Dynamic Form Field module is not enabled |
| 546 |
// This allows the settings to save without error when the module is disabled |
| 547 |
if ($key === 'booking_form_config' && !$is_dynamic_form_enabled) { |
| 548 |
continue; |
| 549 |
} |
| 550 |
|
| 551 |
// Delegate flexible payment settings to Pro module |
| 552 |
if (in_array($key, $flexible_payment_keys, true)) { |
| 553 |
if ($is_flexible_payments_enabled) { |
| 554 |
$flexible_payment_settings[$key] = $value; |
| 555 |
} |
| 556 |
// Skip saving in Free plugin - Pro handles these |
| 557 |
continue; |
| 558 |
} |
| 559 |
|
| 560 |
if (in_array($key, $scheduled_payment_keys, true)) { |
| 561 |
if ($is_scheduled_payments_module) { |
| 562 |
$scheduled_payment_settings_batch[$key] = $value; |
| 563 |
} |
| 564 |
continue; |
| 565 |
} |
| 566 |
|
| 567 |
// Wishlist toggle: only meaningful with Yatra Pro active |
| 568 |
if ($key === 'enable_wishlist' && !apply_filters('yatra_is_pro_active', false)) { |
| 569 |
continue; |
| 570 |
} |
| 571 |
|
| 572 |
// Validate that the key exists in default settings |
| 573 |
if (!array_key_exists($key, $this->default_settings)) { |
| 574 |
$errors[] = sprintf('Unknown setting: %s', $key); |
| 575 |
continue; |
| 576 |
} |
| 577 |
|
| 578 |
// Sanitize and validate the value based on its type |
| 579 |
$sanitized_value = $this->sanitize_setting($key, $value); |
| 580 |
|
| 581 |
if ($sanitized_value === null) { |
| 582 |
$errors[] = sprintf('Invalid value for setting: %s', $key); |
| 583 |
continue; |
| 584 |
} |
| 585 |
|
| 586 |
// Save to WordPress options table with yatra_ prefix |
| 587 |
$option_name = 'yatra_' . $key; |
| 588 |
|
| 589 |
// Serialize arrays for storage |
| 590 |
if (is_array($sanitized_value)) { |
| 591 |
$sanitized_value = maybe_serialize($sanitized_value); |
| 592 |
} |
| 593 |
|
| 594 |
$result = update_option($option_name, $sanitized_value); |
| 595 |
|
| 596 |
if ($result !== false) { |
| 597 |
$updated[] = $key; |
| 598 |
} |
| 599 |
} |
| 600 |
|
| 601 |
// Delegate flexible payment settings to Pro module for saving |
| 602 |
if (!empty($flexible_payment_settings) && $is_flexible_payments_enabled) { |
| 603 |
do_action('yatra_save_flexible_payment_settings', $flexible_payment_settings); |
| 604 |
$updated = array_merge($updated, array_keys($flexible_payment_settings)); |
| 605 |
} |
| 606 |
|
| 607 |
if (!empty($scheduled_payment_settings_batch) && $is_scheduled_payments_module) { |
| 608 |
do_action('yatra_save_scheduled_payment_settings', $scheduled_payment_settings_batch); |
| 609 |
$updated = array_merge($updated, array_keys($scheduled_payment_settings_batch)); |
| 610 |
} |
| 611 |
|
| 612 |
// Sync currency keys: keep 'currency' and 'default_currency' in sync |
| 613 |
// Admin UI has both Payment Settings (currency) and Currency Settings (default_currency) |
| 614 |
if (in_array('default_currency', $updated, true) && !in_array('currency', $updated, true)) { |
| 615 |
$sync_currency = get_option('yatra_default_currency', 'USD'); |
| 616 |
update_option('yatra_currency', $sync_currency); |
| 617 |
} elseif (in_array('currency', $updated, true) && !in_array('default_currency', $updated, true)) { |
| 618 |
$sync_currency = get_option('yatra_currency', 'USD'); |
| 619 |
update_option('yatra_default_currency', $sync_currency); |
| 620 |
} |
| 621 |
|
| 622 |
if (in_array('customer_account_page', $updated, true)) { |
| 623 |
$this->persistAccountBaseFromCustomerAccountPage(); |
| 624 |
} |
| 625 |
|
| 626 |
if (!empty($errors)) { |
| 627 |
$errorSummary = implode('; ', $errors); |
| 628 |
return $this->error_response( |
| 629 |
sprintf('Some settings could not be updated: %s', $errorSummary), |
| 630 |
400, |
| 631 |
[ |
| 632 |
'errors' => $errors, |
| 633 |
'updated' => $updated, |
| 634 |
] |
| 635 |
); |
| 636 |
} |
| 637 |
|
| 638 |
// Flush rewrite rules if permalink settings were updated |
| 639 |
if (in_array('trip_base', $updated, true) || |
| 640 |
in_array('destination_base', $updated, true) || |
| 641 |
in_array('activity_base', $updated, true) || |
| 642 |
in_array('trip_category_base', $updated, true) || |
| 643 |
in_array('booking_base', $updated, true) || |
| 644 |
in_array('use_booking_page', $updated, true) || |
| 645 |
in_array('booking_page_id', $updated, true) || |
| 646 |
in_array('customer_account_page', $updated, true)) { |
| 647 |
// Use hard flush to ensure rules are saved to database |
| 648 |
flush_rewrite_rules(true); |
| 649 |
} |
| 650 |
|
| 651 |
if (!empty($updated)) { |
| 652 |
\Yatra\Services\SettingsService::reload(); |
| 653 |
} |
| 654 |
|
| 655 |
// Cross-validation: booking-auth settings interact via OR |
| 656 |
// logic in booking-content.php, so some combinations are |
| 657 |
// semantically inconsistent or redundant. We don't block |
| 658 |
// the save (the resulting state still has well-defined |
| 659 |
// behavior), but we surface a clear notice so the operator |
| 660 |
// understands what they just configured. |
| 661 |
// |
| 662 |
// require_login=true + allow_guest_checkout=true → |
| 663 |
// require_login wins; allow_guest_checkout is a no-op. |
| 664 |
// require_login=true + allow_guest_checkout=false → |
| 665 |
// Strictest setting (login required, no guest path). |
| 666 |
// Internally consistent. |
| 667 |
// require_login=false + allow_guest_checkout=false → |
| 668 |
// Guests blocked, logged-in users can book. Consistent. |
| 669 |
// require_login=false + allow_guest_checkout=true → |
| 670 |
// Default. Permissive. |
| 671 |
$notices = []; |
| 672 |
$effective_require_login = \array_key_exists('require_login', $data) |
| 673 |
? (bool) $data['require_login'] |
| 674 |
: (bool) \Yatra\Services\SettingsService::get('require_login', false); |
| 675 |
$effective_allow_guest = \array_key_exists('allow_guest_checkout', $data) |
| 676 |
? (bool) $data['allow_guest_checkout'] |
| 677 |
: (bool) \Yatra\Services\SettingsService::get('allow_guest_checkout', true); |
| 678 |
|
| 679 |
if ($effective_require_login && $effective_allow_guest) { |
| 680 |
$notices[] = [ |
| 681 |
'level' => 'warning', |
| 682 |
'code' => 'booking_auth_redundant', |
| 683 |
'message' => __( |
| 684 |
'Heads up: "Require login" is on, so "Allow guest checkout" has no effect — every customer will need to log in to book. To accept guests, turn "Require login" off.', |
| 685 |
'yatra' |
| 686 |
), |
| 687 |
]; |
| 688 |
} |
| 689 |
|
| 690 |
// Scheduled Payments + guest checkout — incompatible at |
| 691 |
// the gateway level. Scheduled charges require a saved |
| 692 |
// payment-method tied to a customer record on the |
| 693 |
// gateway side (Stripe Customer, etc.), which in turn |
| 694 |
// requires a logged-in WP user. When both settings are |
| 695 |
// on, the system gracefully skips installment creation |
| 696 |
// for guest bookings — but operators expect them to |
| 697 |
// work and only discover the gap when reconciling |
| 698 |
// unpaid bookings weeks later. Surface this proactively. |
| 699 |
$effective_scheduled_payments = \array_key_exists('enable_scheduled_payments', $data) |
| 700 |
? (bool) $data['enable_scheduled_payments'] |
| 701 |
: (bool) \Yatra\Services\SettingsService::get('enable_scheduled_payments', false); |
| 702 |
if ( |
| 703 |
$effective_scheduled_payments |
| 704 |
&& $effective_allow_guest |
| 705 |
&& !$effective_require_login |
| 706 |
) { |
| 707 |
$notices[] = [ |
| 708 |
'level' => 'info', |
| 709 |
'code' => 'scheduled_payments_guest_caveat', |
| 710 |
'message' => __( |
| 711 |
'Scheduled Payments is on with guest checkout allowed. Scheduled installments only run for bookings made by logged-in customers (they need a saved payment method tied to their account). Guest bookings will be charged in full at checkout instead. Turn on "Require login" if every booking must support installments.', |
| 712 |
'yatra' |
| 713 |
), |
| 714 |
]; |
| 715 |
} |
| 716 |
|
| 717 |
$response = [ |
| 718 |
'message' => 'Settings updated successfully', |
| 719 |
'updated' => $updated, |
| 720 |
]; |
| 721 |
if ($notices !== []) { |
| 722 |
$response['notices'] = $notices; |
| 723 |
} |
| 724 |
return $this->success_response($response); |
| 725 |
} catch (\Exception $e) { |
| 726 |
return $this->error_response($e->getMessage(), 500); |
| 727 |
} |
| 728 |
} |
| 729 |
|
| 730 |
/** |
| 731 |
* Sanitize and validate setting value |
| 732 |
* Pro plugins can handle sanitization of their own settings via filter |
| 733 |
* |
| 734 |
* @param mixed $value |
| 735 |
* @return mixed |
| 736 |
*/ |
| 737 |
private function sanitize_setting(string $key, $value) |
| 738 |
{ |
| 739 |
$default = $this->default_settings[$key] ?? null; |
| 740 |
$default_type = gettype($default); |
| 741 |
|
| 742 |
// Allow Pro plugins to handle sanitization of their own settings |
| 743 |
$filtered_value = apply_filters('yatra_sanitize_setting', null, $key, $value, $default); |
| 744 |
if ($filtered_value !== null) { |
| 745 |
return $filtered_value; |
| 746 |
} |
| 747 |
|
| 748 |
// Handle null values - use default |
| 749 |
if ($value === null) { |
| 750 |
return $default; |
| 751 |
} |
| 752 |
|
| 753 |
// Handle arrays |
| 754 |
if (is_array($default)) { |
| 755 |
if (!is_array($value)) { |
| 756 |
return null; |
| 757 |
} |
| 758 |
// Sanitize array values |
| 759 |
return array_map(function($item) { |
| 760 |
if (is_string($item)) { |
| 761 |
return sanitize_text_field($item); |
| 762 |
} |
| 763 |
if (is_numeric($item)) { |
| 764 |
return is_float($item) ? (float) $item : (int) $item; |
| 765 |
} |
| 766 |
if (is_bool($item)) { |
| 767 |
return (bool) $item; |
| 768 |
} |
| 769 |
if (is_array($item)) { |
| 770 |
return $this->sanitize_array($item); |
| 771 |
} |
| 772 |
return $item; |
| 773 |
}, $value); |
| 774 |
} |
| 775 |
|
| 776 |
// Handle booleans (REST may send true/false strings) |
| 777 |
if (is_bool($default)) { |
| 778 |
if (is_bool($value)) { |
| 779 |
return $value; |
| 780 |
} |
| 781 |
if (is_string($value)) { |
| 782 |
$parsed = filter_var($value, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE); |
| 783 |
return $parsed !== null ? $parsed : (bool) $value; |
| 784 |
} |
| 785 |
return (bool) $value; |
| 786 |
} |
| 787 |
|
| 788 |
// Handle integers |
| 789 |
if (is_int($default)) { |
| 790 |
if (!is_numeric($value)) { |
| 791 |
return null; |
| 792 |
} |
| 793 |
$int_value = (int) $value; |
| 794 |
// Validate ranges for specific fields |
| 795 |
if ($key === 'booking_expiry_hours' && $int_value < 0) { |
| 796 |
return null; |
| 797 |
} |
| 798 |
if ($key === 'partial_payment_percentage' && ($int_value < 0 || $int_value > 100)) { |
| 799 |
return null; |
| 800 |
} |
| 801 |
if ($key === 'deposit_percentage' && ($int_value < 0 || $int_value > 100)) { |
| 802 |
return null; |
| 803 |
} |
| 804 |
if ($key === 'tax_rate' && ($int_value < 0 || $int_value > 100)) { |
| 805 |
return null; |
| 806 |
} |
| 807 |
if ($key === 'smtp_port' && ($int_value < 1 || $int_value > 65535)) { |
| 808 |
return null; |
| 809 |
} |
| 810 |
return $int_value; |
| 811 |
} |
| 812 |
|
| 813 |
// Handle floats |
| 814 |
if (is_float($default)) { |
| 815 |
if (!is_numeric($value)) { |
| 816 |
return null; |
| 817 |
} |
| 818 |
$float_value = (float) $value; |
| 819 |
if ($float_value < 0) { |
| 820 |
return null; |
| 821 |
} |
| 822 |
return $float_value; |
| 823 |
} |
| 824 |
|
| 825 |
// Handle strings |
| 826 |
if (is_string($default)) { |
| 827 |
if ($key === 'timezone') { |
| 828 |
$tz = is_string($value) ? trim($value) : ''; |
| 829 |
if ($tz === '') { |
| 830 |
return is_string($default) ? $default : 'UTC'; |
| 831 |
} |
| 832 |
try { |
| 833 |
new \DateTimeZone($tz); |
| 834 |
|
| 835 |
return $tz; |
| 836 |
} catch (\Exception $e) { |
| 837 |
return is_string($default) ? $default : 'UTC'; |
| 838 |
} |
| 839 |
} |
| 840 |
if ($key === 'currency_position') { |
| 841 |
$allowed = ['left', 'right', 'left_space', 'right_space', 'before', 'after']; |
| 842 |
$v = is_string($value) ? strtolower(trim($value)) : ''; |
| 843 |
|
| 844 |
return in_array($v, $allowed, true) ? $v : (is_string($default) ? $default : 'left'); |
| 845 |
} |
| 846 |
if ($key === 'discount_stacking_mode') { |
| 847 |
// Strict enum — any other value silently falls back to the |
| 848 |
// backward-compatible default so a malformed POST cannot |
| 849 |
// change pricing behavior unexpectedly. |
| 850 |
$allowed = ['both', 'discount_only', 'dynamic_pricing_only', 'best_for_customer']; |
| 851 |
$v = is_string($value) ? strtolower(trim($value)) : ''; |
| 852 |
|
| 853 |
return in_array($v, $allowed, true) ? $v : 'both'; |
| 854 |
} |
| 855 |
// Special handling for specific fields |
| 856 |
if ($key === 'company_email' || $key === 'admin_email' || $key === 'from_email' || $key === 'smtp_username') { |
| 857 |
return sanitize_email($value); |
| 858 |
} |
| 859 |
if ($key === 'company_website' || $key === 'company_logo' || $key === 'google_analytics' || $key === 'facebook_pixel') { |
| 860 |
return esc_url_raw($value); |
| 861 |
} |
| 862 |
if ($key === 'seo_trip_meta_title') { |
| 863 |
// Allow more characters for meta title, but strip HTML |
| 864 |
return wp_strip_all_tags($value); |
| 865 |
} |
| 866 |
if ($key === 'seo_trip_meta_description') { |
| 867 |
// Allow more characters for meta description, but strip HTML |
| 868 |
return wp_strip_all_tags($value); |
| 869 |
} |
| 870 |
if ($key === 'seo_trip_meta_keywords') { |
| 871 |
// Allow keywords, strip HTML and sanitize |
| 872 |
return sanitize_text_field($value); |
| 873 |
} |
| 874 |
if ($key === 'frontend_primary_color') { |
| 875 |
return \Yatra\Utils\FrontendThemeCss::sanitizePrimaryColor(is_string($value) ? $value : ''); |
| 876 |
} |
| 877 |
if ($key === 'frontend_container_max_width') { |
| 878 |
return \Yatra\Utils\FrontendThemeCss::sanitizeContainerMaxWidthSetting( |
| 879 |
is_string($value) ? $value : '' |
| 880 |
); |
| 881 |
} |
| 882 |
if (is_string($key) && strpos($key, 'email_tpl_') === 0 && substr($key, -5) === '_body') { |
| 883 |
return wp_kses_post((string) $value); |
| 884 |
} |
| 885 |
if (is_string($key) && strpos($key, 'email_tpl_') === 0 && substr($key, -8) === '_subject') { |
| 886 |
return sanitize_text_field((string) $value); |
| 887 |
} |
| 888 |
if ($key === 'smtp_password' || $key === 'api_key' || $key === 'sms_api_key' || $key === 'recaptcha_secret_key') { |
| 889 |
// Don't sanitize passwords/keys too aggressively |
| 890 |
return sanitize_text_field($value); |
| 891 |
} |
| 892 |
if ($key === 'gateway_configs') { |
| 893 |
// Handle nested array structure for gateway configs |
| 894 |
if (is_array($value)) { |
| 895 |
return $this->sanitize_gateway_configs($value); |
| 896 |
} |
| 897 |
return []; |
| 898 |
} |
| 899 |
if ($key === 'booking_form_config') { |
| 900 |
// Handle nested array structure for booking form config |
| 901 |
if (is_array($value)) { |
| 902 |
return $this->sanitize_booking_form_config($value); |
| 903 |
} |
| 904 |
return []; |
| 905 |
} |
| 906 |
if ($key === 'tax_rates') { |
| 907 |
// Handle nested array structure for tax rates |
| 908 |
if (is_array($value)) { |
| 909 |
return $this->sanitize_tax_rates($value); |
| 910 |
} |
| 911 |
return []; |
| 912 |
} |
| 913 |
return sanitize_text_field($value); |
| 914 |
} |
| 915 |
|
| 916 |
return $value; |
| 917 |
} |
| 918 |
|
| 919 |
/** |
| 920 |
* Sanitize nested array |
| 921 |
*/ |
| 922 |
private function sanitize_array(array $array): array |
| 923 |
{ |
| 924 |
$sanitized = []; |
| 925 |
foreach ($array as $k => $v) { |
| 926 |
$sanitized_key = is_string($k) ? sanitize_key($k) : $k; |
| 927 |
if (is_array($v)) { |
| 928 |
$sanitized[$sanitized_key] = $this->sanitize_array($v); |
| 929 |
} elseif (is_string($v)) { |
| 930 |
$sanitized[$sanitized_key] = sanitize_text_field($v); |
| 931 |
} elseif (is_numeric($v)) { |
| 932 |
$sanitized[$sanitized_key] = is_float($v) ? (float) $v : (int) $v; |
| 933 |
} elseif (is_bool($v)) { |
| 934 |
$sanitized[$sanitized_key] = (bool) $v; |
| 935 |
} else { |
| 936 |
$sanitized[$sanitized_key] = $v; |
| 937 |
} |
| 938 |
} |
| 939 |
return $sanitized; |
| 940 |
} |
| 941 |
|
| 942 |
/** |
| 943 |
* Sanitize gateway configs |
| 944 |
*/ |
| 945 |
private function sanitize_gateway_configs(array $configs): array |
| 946 |
{ |
| 947 |
$sanitized = []; |
| 948 |
foreach ($configs as $gateway => $config) { |
| 949 |
if (!is_array($config)) { |
| 950 |
continue; |
| 951 |
} |
| 952 |
$sanitized_gateway = sanitize_key($gateway); |
| 953 |
$row = [ |
| 954 |
'enabled' => isset($config['enabled']) ? (bool) $config['enabled'] : false, |
| 955 |
'icon' => isset($config['icon']) ? esc_url_raw($config['icon']) : '', |
| 956 |
'title' => isset($config['title']) ? sanitize_text_field($config['title']) : '', |
| 957 |
'description' => isset($config['description']) ? sanitize_textarea_field($config['description']) : '', |
| 958 |
'api_key' => isset($config['api_key']) ? sanitize_text_field($config['api_key']) : '', |
| 959 |
'api_secret' => isset($config['api_secret']) ? sanitize_text_field($config['api_secret']) : '', |
| 960 |
'client_id' => isset($config['client_id']) ? sanitize_text_field($config['client_id']) : '', |
| 961 |
'client_secret' => isset($config['client_secret']) ? sanitize_text_field($config['client_secret']) : '', |
| 962 |
'merchant_id' => isset($config['merchant_id']) ? sanitize_text_field($config['merchant_id']) : '', |
| 963 |
'public_key' => isset($config['public_key']) ? sanitize_text_field($config['public_key']) : '', |
| 964 |
'private_key' => isset($config['private_key']) ? sanitize_text_field($config['private_key']) : '', |
| 965 |
'webhook_secret' => isset($config['webhook_secret']) ? sanitize_text_field($config['webhook_secret']) : '', |
| 966 |
'test_mode' => isset($config['test_mode']) ? (bool) $config['test_mode'] : false, |
| 967 |
'sandbox' => isset($config['sandbox']) ? (bool) $config['sandbox'] : false, |
| 968 |
]; |
| 969 |
|
| 970 |
if ($sanitized_gateway === 'paypal') { |
| 971 |
$mode = isset($config['mode']) && in_array((string) $config['mode'], ['simple', 'advanced'], true) |
| 972 |
? (string) $config['mode'] |
| 973 |
: 'simple'; |
| 974 |
$row['email'] = isset($config['email']) ? sanitize_email((string) $config['email']) : ''; |
| 975 |
$row['mode'] = $mode; |
| 976 |
} |
| 977 |
|
| 978 |
if ($sanitized_gateway === 'pay_later') { |
| 979 |
$row['payment_deadline_days'] = isset($config['payment_deadline_days']) |
| 980 |
? max(1, min(60, (int) $config['payment_deadline_days'])) |
| 981 |
: 7; |
| 982 |
$row['auto_cancel_days'] = isset($config['auto_cancel_days']) |
| 983 |
? max(0, min(30, (int) $config['auto_cancel_days'])) |
| 984 |
: 3; |
| 985 |
$row['require_deposit'] = isset($config['require_deposit']) ? (bool) $config['require_deposit'] : false; |
| 986 |
$row['deposit_amount'] = isset($config['deposit_amount']) |
| 987 |
? max(1, min(50, (int) $config['deposit_amount'])) |
| 988 |
: 10; |
| 989 |
$row['reminder_days'] = isset($config['reminder_days']) |
| 990 |
? sanitize_text_field((string) $config['reminder_days']) |
| 991 |
: '7,3,1'; |
| 992 |
} |
| 993 |
|
| 994 |
if ($sanitized_gateway === 'stripe') { |
| 995 |
$allowedStripeMethods = ['card', 'google_pay', 'apple_pay']; |
| 996 |
$methodsRaw = isset($config['enabled_methods']) ? (string) $config['enabled_methods'] : ''; |
| 997 |
if ($methodsRaw !== '') { |
| 998 |
$parts = array_filter(array_map('trim', explode(',', $methodsRaw))); |
| 999 |
$normalized = []; |
| 1000 |
foreach ($parts as $part) { |
| 1001 |
$slug = strtolower($part); |
| 1002 |
if (in_array($slug, $allowedStripeMethods, true)) { |
| 1003 |
$normalized[] = $slug; |
| 1004 |
} |
| 1005 |
} |
| 1006 |
$row['enabled_methods'] = $normalized !== [] ? implode(',', $normalized) : 'card,google_pay,apple_pay'; |
| 1007 |
} else { |
| 1008 |
$row['enabled_methods'] = 'card,google_pay,apple_pay'; |
| 1009 |
} |
| 1010 |
foreach (['live_publishable_key', 'live_secret_key', 'test_publishable_key', 'test_secret_key'] as $stripeKey) { |
| 1011 |
if (array_key_exists($stripeKey, $config)) { |
| 1012 |
$row[$stripeKey] = sanitize_text_field((string) $config[$stripeKey]); |
| 1013 |
} |
| 1014 |
} |
| 1015 |
} |
| 1016 |
|
| 1017 |
if ($sanitized_gateway === 'razorpay') { |
| 1018 |
$row['key_id'] = isset($config['key_id']) ? sanitize_text_field((string) $config['key_id']) : ''; |
| 1019 |
$row['key_secret'] = isset($config['key_secret']) ? sanitize_text_field((string) $config['key_secret']) : ''; |
| 1020 |
} |
| 1021 |
|
| 1022 |
if ($sanitized_gateway === 'mollie') { |
| 1023 |
$row['api_key'] = isset($config['api_key']) ? sanitize_text_field((string) $config['api_key']) : ''; |
| 1024 |
$row['webhook_url'] = isset($config['webhook_url']) ? esc_url_raw((string) $config['webhook_url']) : ''; |
| 1025 |
$allowedMollie = ['creditcard', 'ideal', 'bancontact', 'sofort', 'eps', 'giropay', 'paypal', 'sepadirectdebit']; |
| 1026 |
$row['payment_methods'] = $this->sanitizeGatewayStringList( |
| 1027 |
$config['payment_methods'] ?? [], |
| 1028 |
$allowedMollie, |
| 1029 |
['creditcard', 'ideal', 'paypal'] |
| 1030 |
); |
| 1031 |
} |
| 1032 |
|
| 1033 |
if ($sanitized_gateway === 'paystack') { |
| 1034 |
$row['public_key'] = isset($config['public_key']) ? sanitize_text_field((string) $config['public_key']) : ''; |
| 1035 |
$row['secret_key'] = isset($config['secret_key']) ? sanitize_text_field((string) $config['secret_key']) : ''; |
| 1036 |
$row['webhook_url'] = isset($config['webhook_url']) ? esc_url_raw((string) $config['webhook_url']) : ''; |
| 1037 |
$allowedPaystack = ['card', 'bank', 'ussd', 'qr', 'mobile_money', 'bank_transfer']; |
| 1038 |
$row['payment_channels'] = $this->sanitizeGatewayStringList( |
| 1039 |
$config['payment_channels'] ?? [], |
| 1040 |
$allowedPaystack, |
| 1041 |
['card', 'bank', 'ussd'] |
| 1042 |
); |
| 1043 |
unset($row['private_key']); |
| 1044 |
} |
| 1045 |
|
| 1046 |
if ($sanitized_gateway === 'square') { |
| 1047 |
$row['application_id'] = isset($config['application_id']) ? sanitize_text_field((string) $config['application_id']) : ''; |
| 1048 |
$row['access_token'] = isset($config['access_token']) ? sanitize_text_field((string) $config['access_token']) : ''; |
| 1049 |
$row['location_id'] = isset($config['location_id']) ? sanitize_text_field((string) $config['location_id']) : ''; |
| 1050 |
} |
| 1051 |
|
| 1052 |
if ($sanitized_gateway === 'authorize_net') { |
| 1053 |
$row['api_login_id'] = isset($config['api_login_id']) ? sanitize_text_field((string) $config['api_login_id']) : ''; |
| 1054 |
$row['transaction_key'] = isset($config['transaction_key']) ? sanitize_text_field((string) $config['transaction_key']) : ''; |
| 1055 |
$row['public_client_key'] = isset($config['public_client_key']) ? sanitize_text_field((string) $config['public_client_key']) : ''; |
| 1056 |
} |
| 1057 |
|
| 1058 |
if ($sanitized_gateway === 'bank_transfer') { |
| 1059 |
$row['bank_name'] = isset($config['bank_name']) ? sanitize_text_field((string) $config['bank_name']) : ''; |
| 1060 |
$row['account_name'] = isset($config['account_name']) ? sanitize_text_field((string) $config['account_name']) : ''; |
| 1061 |
$row['account_number'] = isset($config['account_number']) ? sanitize_text_field((string) $config['account_number']) : ''; |
| 1062 |
$row['routing_code'] = isset($config['routing_code']) ? sanitize_text_field((string) $config['routing_code']) : ''; |
| 1063 |
$row['instructions'] = isset($config['instructions']) ? sanitize_textarea_field((string) $config['instructions']) : ''; |
| 1064 |
} |
| 1065 |
|
| 1066 |
/** |
| 1067 |
* Allow Pro add-ons or custom code to append keys after core sanitization. |
| 1068 |
* |
| 1069 |
* @param array<string, mixed> $row |
| 1070 |
* @param array<string, mixed> $config |
| 1071 |
* @return array<string, mixed> |
| 1072 |
*/ |
| 1073 |
$row = apply_filters('yatra_sanitize_gateway_config_row', $row, $sanitized_gateway, $config); |
| 1074 |
|
| 1075 |
$sanitized[$sanitized_gateway] = $row; |
| 1076 |
} |
| 1077 |
return $sanitized; |
| 1078 |
} |
| 1079 |
|
| 1080 |
/** |
| 1081 |
* Normalize multiselect gateway options (Mollie methods, Paystack channels, etc.). |
| 1082 |
* |
| 1083 |
* @param mixed $input |
| 1084 |
* @param array<int, string> $allowed |
| 1085 |
* @param array<int, string> $default |
| 1086 |
* @return array<int, string> |
| 1087 |
*/ |
| 1088 |
private function sanitizeGatewayStringList($input, array $allowed, array $default): array |
| 1089 |
{ |
| 1090 |
if (is_string($input) && $input !== '') { |
| 1091 |
$input = array_map('trim', explode(',', $input)); |
| 1092 |
} |
| 1093 |
if (!is_array($input)) { |
| 1094 |
return $default; |
| 1095 |
} |
| 1096 |
$out = []; |
| 1097 |
foreach ($input as $v) { |
| 1098 |
$slug = sanitize_key((string) $v); |
| 1099 |
if ($slug !== '' && in_array($slug, $allowed, true)) { |
| 1100 |
$out[] = $slug; |
| 1101 |
} |
| 1102 |
} |
| 1103 |
$out = array_values(array_unique($out)); |
| 1104 |
|
| 1105 |
return $out !== [] ? $out : $default; |
| 1106 |
} |
| 1107 |
|
| 1108 |
/** |
| 1109 |
* Sanitize tax rates |
| 1110 |
*/ |
| 1111 |
private function sanitize_tax_rates(array $rates): array |
| 1112 |
{ |
| 1113 |
$sanitized = []; |
| 1114 |
foreach ($rates as $country => $rate) { |
| 1115 |
$sanitized_country = sanitize_text_field($country); |
| 1116 |
if (is_numeric($rate)) { |
| 1117 |
$float_rate = (float) $rate; |
| 1118 |
if ($float_rate >= 0 && $float_rate <= 100) { |
| 1119 |
$sanitized[$sanitized_country] = $float_rate; |
| 1120 |
} |
| 1121 |
} |
| 1122 |
} |
| 1123 |
return $sanitized; |
| 1124 |
} |
| 1125 |
|
| 1126 |
/** |
| 1127 |
* Sanitize booking form configuration |
| 1128 |
*/ |
| 1129 |
private function sanitize_booking_form_config(array $config): array |
| 1130 |
{ |
| 1131 |
$sanitized = []; |
| 1132 |
$allowed_form_types = ['contact_form', 'emergency_contact_form', 'traveler_form']; |
| 1133 |
$allowed_field_types = ['text', 'email', 'tel', 'date', 'select', 'country', 'textarea', 'checkbox', 'number', 'text_block']; |
| 1134 |
$allowed_widths = ['full', 'half', 'third']; |
| 1135 |
|
| 1136 |
foreach ($config as $form_type => $form_config) { |
| 1137 |
if (!in_array($form_type, $allowed_form_types, true)) { |
| 1138 |
continue; |
| 1139 |
} |
| 1140 |
|
| 1141 |
$sanitized[$form_type] = [ |
| 1142 |
'title' => isset($form_config['title']) ? sanitize_text_field($form_config['title']) : '', |
| 1143 |
'description' => isset($form_config['description']) ? sanitize_text_field($form_config['description']) : '', |
| 1144 |
'enabled' => isset($form_config['enabled']) ? (bool) $form_config['enabled'] : true, |
| 1145 |
'fields' => [], |
| 1146 |
]; |
| 1147 |
|
| 1148 |
if (!empty($form_config['fields']) && is_array($form_config['fields'])) { |
| 1149 |
foreach ($form_config['fields'] as $field) { |
| 1150 |
if (!is_array($field) || empty($field['id'])) { |
| 1151 |
continue; |
| 1152 |
} |
| 1153 |
|
| 1154 |
$sanitized_field = [ |
| 1155 |
'id' => sanitize_key($field['id']), |
| 1156 |
'type' => in_array($field['type'] ?? 'text', $allowed_field_types, true) ? $field['type'] : 'text', |
| 1157 |
'label' => isset($field['label']) ? sanitize_text_field($field['label']) : '', |
| 1158 |
'placeholder' => isset($field['placeholder']) ? sanitize_text_field($field['placeholder']) : '', |
| 1159 |
'required' => isset($field['required']) ? (bool) $field['required'] : false, |
| 1160 |
'enabled' => isset($field['enabled']) ? (bool) $field['enabled'] : true, |
| 1161 |
'order' => isset($field['order']) ? (int) $field['order'] : 0, |
| 1162 |
'width' => in_array($field['width'] ?? 'full', $allowed_widths, true) ? ($field['width'] ?? 'full') : 'full', |
| 1163 |
'locked' => isset($field['locked']) ? (bool) $field['locked'] : false, |
| 1164 |
]; |
| 1165 |
|
| 1166 |
// Handle optional section |
| 1167 |
if (!empty($field['section'])) { |
| 1168 |
$sanitized_field['section'] = sanitize_key($field['section']); |
| 1169 |
} |
| 1170 |
|
| 1171 |
// Per-traveler targeting — Traveler section only. Whitelist |
| 1172 |
// the allowed values; only persist the non-default "lead" so |
| 1173 |
// other sections and existing configs stay byte-identical. |
| 1174 |
if ( |
| 1175 |
$form_type === 'traveler_form' |
| 1176 |
&& ($field['applies_to'] ?? 'all') === 'lead' |
| 1177 |
) { |
| 1178 |
$sanitized_field['applies_to'] = 'lead'; |
| 1179 |
} |
| 1180 |
|
| 1181 |
// Handle options for select fields |
| 1182 |
if ($sanitized_field['type'] === 'select' && !empty($field['options']) && is_array($field['options'])) { |
| 1183 |
$sanitized_field['options'] = []; |
| 1184 |
foreach ($field['options'] as $option) { |
| 1185 |
if (is_array($option) && isset($option['value'])) { |
| 1186 |
$sanitized_field['options'][] = [ |
| 1187 |
'value' => sanitize_key($option['value']), |
| 1188 |
'label' => isset($option['label']) ? sanitize_text_field($option['label']) : $option['value'], |
| 1189 |
]; |
| 1190 |
} |
| 1191 |
} |
| 1192 |
} |
| 1193 |
|
| 1194 |
// A text block is display-only content placed between fields: |
| 1195 |
// keep its (safe-HTML) content, and it can never be required. |
| 1196 |
if ($sanitized_field['type'] === 'text_block') { |
| 1197 |
$sanitized_field['content'] = isset($field['content']) ? wp_kses_post($field['content']) : ''; |
| 1198 |
$sanitized_field['required'] = false; |
| 1199 |
} |
| 1200 |
|
| 1201 |
// Phone fields: the country-code selector is ON by default. |
| 1202 |
// Only persist the non-default `false`, so existing configs |
| 1203 |
// (which never carried this key) stay byte-identical and read |
| 1204 |
// back as ON. |
| 1205 |
if ( |
| 1206 |
$sanitized_field['type'] === 'tel' |
| 1207 |
&& array_key_exists('show_country_code', $field) |
| 1208 |
&& !$field['show_country_code'] |
| 1209 |
) { |
| 1210 |
$sanitized_field['show_country_code'] = false; |
| 1211 |
} |
| 1212 |
|
| 1213 |
$sanitized[$form_type]['fields'][] = $sanitized_field; |
| 1214 |
} |
| 1215 |
|
| 1216 |
// Sort fields by order |
| 1217 |
usort($sanitized[$form_type]['fields'], function($a, $b) { |
| 1218 |
return ($a['order'] ?? 0) - ($b['order'] ?? 0); |
| 1219 |
}); |
| 1220 |
} |
| 1221 |
} |
| 1222 |
|
| 1223 |
return apply_filters('yatra_save_booking_form_config', $sanitized, $config); |
| 1224 |
} |
| 1225 |
|
| 1226 |
/** |
| 1227 |
* Flush rewrite rules |
| 1228 |
*/ |
| 1229 |
public function flush_rewrite_rules(WP_REST_Request $request) |
| 1230 |
{ |
| 1231 |
try { |
| 1232 |
// Flush rewrite rules |
| 1233 |
flush_rewrite_rules(true); |
| 1234 |
|
| 1235 |
return $this->success_response([ |
| 1236 |
'message' => 'Rewrite rules flushed successfully', |
| 1237 |
]); |
| 1238 |
} catch (\Exception $e) { |
| 1239 |
return $this->error_response($e->getMessage(), 500); |
| 1240 |
} |
| 1241 |
} |
| 1242 |
|
| 1243 |
/** |
| 1244 |
* Get list of WordPress pages for booking page selection |
| 1245 |
* Note: We don't check for shortcode here - it's checked on-demand when user selects a page |
| 1246 |
*/ |
| 1247 |
public function get_pages(WP_REST_Request $request) |
| 1248 |
{ |
| 1249 |
try { |
| 1250 |
$pages = get_pages([ |
| 1251 |
'post_status' => 'publish', |
| 1252 |
'sort_column' => 'post_title', |
| 1253 |
'sort_order' => 'ASC', |
| 1254 |
]); |
| 1255 |
|
| 1256 |
$page_list = []; |
| 1257 |
foreach ($pages as $page) { |
| 1258 |
$page_list[] = [ |
| 1259 |
'id' => $page->ID, |
| 1260 |
'title' => $page->post_title, |
| 1261 |
'slug' => $page->post_name, |
| 1262 |
'url' => get_permalink($page->ID), |
| 1263 |
]; |
| 1264 |
} |
| 1265 |
|
| 1266 |
return $this->success_response($page_list); |
| 1267 |
} catch (\Exception $e) { |
| 1268 |
return $this->error_response($e->getMessage(), 500); |
| 1269 |
} |
| 1270 |
} |
| 1271 |
|
| 1272 |
/** |
| 1273 |
* Check if a page has the booking shortcode |
| 1274 |
*/ |
| 1275 |
public function check_booking_shortcode(WP_REST_Request $request) |
| 1276 |
{ |
| 1277 |
try { |
| 1278 |
$page_id = (int) $request->get_param('page_id'); |
| 1279 |
|
| 1280 |
if ($page_id <= 0) { |
| 1281 |
return $this->error_response('Invalid page ID', 400); |
| 1282 |
} |
| 1283 |
|
| 1284 |
$page = get_post($page_id); |
| 1285 |
|
| 1286 |
if (!$page || $page->post_type !== 'page') { |
| 1287 |
return $this->error_response('Page not found', 404); |
| 1288 |
} |
| 1289 |
|
| 1290 |
$has_shortcode = has_shortcode($page->post_content, 'yatra_booking'); |
| 1291 |
|
| 1292 |
return $this->success_response([ |
| 1293 |
'page_id' => $page_id, |
| 1294 |
'has_shortcode' => $has_shortcode, |
| 1295 |
'page_title' => $page->post_title, |
| 1296 |
'page_url' => get_permalink($page_id), |
| 1297 |
'edit_url' => get_edit_post_link($page_id, 'raw'), |
| 1298 |
]); |
| 1299 |
} catch (\Exception $e) { |
| 1300 |
return $this->error_response($e->getMessage(), 500); |
| 1301 |
} |
| 1302 |
} |
| 1303 |
|
| 1304 |
/** |
| 1305 |
* Insert booking shortcode into a page |
| 1306 |
*/ |
| 1307 |
public function insert_booking_shortcode(WP_REST_Request $request) |
| 1308 |
{ |
| 1309 |
try { |
| 1310 |
$page_id = (int) $request->get_param('page_id'); |
| 1311 |
|
| 1312 |
if ($page_id <= 0) { |
| 1313 |
return $this->error_response('Invalid page ID', 400); |
| 1314 |
} |
| 1315 |
|
| 1316 |
$page = get_post($page_id); |
| 1317 |
|
| 1318 |
if (!$page || $page->post_type !== 'page') { |
| 1319 |
return $this->error_response('Page not found', 404); |
| 1320 |
} |
| 1321 |
|
| 1322 |
// Check if shortcode already exists |
| 1323 |
if (has_shortcode($page->post_content, 'yatra_booking')) { |
| 1324 |
return $this->success_response([ |
| 1325 |
'message' => 'Shortcode already exists on this page', |
| 1326 |
'page_id' => $page_id, |
| 1327 |
'already_exists' => true, |
| 1328 |
]); |
| 1329 |
} |
| 1330 |
|
| 1331 |
// Append shortcode to page content |
| 1332 |
$new_content = $page->post_content . "\n\n[yatra_booking]"; |
| 1333 |
|
| 1334 |
$result = wp_update_post([ |
| 1335 |
'ID' => $page_id, |
| 1336 |
'post_content' => $new_content, |
| 1337 |
], true); |
| 1338 |
|
| 1339 |
if (is_wp_error($result)) { |
| 1340 |
return $this->error_response($result->get_error_message(), 500); |
| 1341 |
} |
| 1342 |
|
| 1343 |
return $this->success_response([ |
| 1344 |
'message' => 'Shortcode added successfully', |
| 1345 |
'page_id' => $page_id, |
| 1346 |
'page_url' => get_permalink($page_id), |
| 1347 |
]); |
| 1348 |
} catch (\Exception $e) { |
| 1349 |
return $this->error_response($e->getMessage(), 500); |
| 1350 |
} |
| 1351 |
} |
| 1352 |
|
| 1353 |
/** |
| 1354 |
* Keep Settings → Customer "account page" path aligned with {@see RouteMatcher} / {@see Router} (yatra_account_base). |
| 1355 |
* |
| 1356 |
* @param array<string, mixed> $settings |
| 1357 |
* @return array<string, mixed> |
| 1358 |
*/ |
| 1359 |
private function syncAccountRouteSettingsForResponse(array $settings): array |
| 1360 |
{ |
| 1361 |
// Prefer the full saved path so admin "View" matches Settings → Customer (not only yatra_account_base slug). |
| 1362 |
$savedPath = get_option('yatra_customer_account_page', ''); |
| 1363 |
if (is_string($savedPath) && $savedPath !== '' && $savedPath !== '0') { |
| 1364 |
$normalized = '/' . trim(str_replace('\\', '/', $savedPath), '/'); |
| 1365 |
if ($normalized === '/') { |
| 1366 |
$normalized = '/my-account'; |
| 1367 |
} |
| 1368 |
$settings['customer_account_page'] = $normalized; |
| 1369 |
|
| 1370 |
return $settings; |
| 1371 |
} |
| 1372 |
|
| 1373 |
$stored = get_option('yatra_account_base', ''); |
| 1374 |
if (is_string($stored) && $stored !== '') { |
| 1375 |
$settings['customer_account_page'] = '/' . $stored; |
| 1376 |
|
| 1377 |
return $settings; |
| 1378 |
} |
| 1379 |
|
| 1380 |
$cpp = (string) ($settings['customer_account_page'] ?? ''); |
| 1381 |
$slug = self::accountSlugFromCustomerAccountPath($cpp !== '' ? $cpp : '/account'); |
| 1382 |
update_option('yatra_account_base', $slug); |
| 1383 |
$settings['customer_account_page'] = '/' . $slug; |
| 1384 |
|
| 1385 |
return $settings; |
| 1386 |
} |
| 1387 |
|
| 1388 |
private function persistAccountBaseFromCustomerAccountPage(): void |
| 1389 |
{ |
| 1390 |
$cpp = (string) get_option('yatra_customer_account_page', ''); |
| 1391 |
update_option('yatra_account_base', self::accountSlugFromCustomerAccountPath($cpp)); |
| 1392 |
} |
| 1393 |
|
| 1394 |
private static function accountSlugFromCustomerAccountPath(string $path): string |
| 1395 |
{ |
| 1396 |
$path = trim(str_replace('\\', '/', $path), '/'); |
| 1397 |
$parts = array_values(array_filter(explode('/', $path), static fn ($p) => $p !== '')); |
| 1398 |
$segment = $parts !== [] ? end($parts) : 'account'; |
| 1399 |
$slug = sanitize_title($segment); |
| 1400 |
|
| 1401 |
return $slug !== '' ? $slug : 'account'; |
| 1402 |
} |
| 1403 |
} |
| 1404 |
|
| 1405 |
|