/** * Stripe Payment Integration for Yatra * Supports Stripe Elements (card) and Payment Request (Google Pay / Apple Pay) */ const __ = (window.wp && window.wp.i18n && window.wp.i18n.__) ? window.wp.i18n.__ : (text => text); class YatraStripe { constructor(apiUrl, nonce) { this.apiUrl = apiUrl; this.nonce = nonce; this.stripe = null; this.elements = null; this.cardElement = null; this.paymentRequest = null; this.paymentRequestButton = null; this.paymentRequestMounted = false; this.paymentForm = document.getElementById('yatra-booking-form'); this.submitButton = document.getElementById('yatra-submit-booking'); this.originalButtonHtml = this.submitButton ? this.submitButton.innerHTML : ''; this.methodButtons = {}; this.activeMethod = null; this.cardWrapper = null; this.walletWrapper = null; this.walletCapabilities = { google_pay: false, apple_pay: false, }; this.walletMethodHints = { google_pay: '', apple_pay: '', }; this.walletAvailabilityKnown = false; /** Short labels for each method switcher option (tooltip base copy). */ this.methodHelpText = {}; this.stripeSettings = window.yatraBookingData?.stripe || {}; let enabledMethods = this.stripeSettings.enabledMethods || ['card']; if (typeof enabledMethods === 'string') { enabledMethods = enabledMethods.split(','); } if (!Array.isArray(enabledMethods) || enabledMethods.length === 0) { enabledMethods = ['card']; } this.enabledMethods = enabledMethods.map((method) => method.trim().toLowerCase()).filter(Boolean); if (this.enabledMethods.length === 0) { this.enabledMethods = ['card']; } this.supportsCard = this.enabledMethods.includes('card'); this.supportsPaymentRequest = this.enabledMethods.some((method) => method === 'google_pay' || method === 'apple_pay'); this.companyCountry = window.yatraBookingData?.companyCountry || 'US'; this.tripTitle = window.yatraBookingData?.tripTitle || 'Trip Booking'; window.yatraStripeInstance = this; if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', () => this.init()); } else { this.init(); } } init() { if (!document.querySelector('input[name="payment_gateway"][value="stripe"]')) { return; } this.addStripeElementsContainer(); document.querySelectorAll('input[name="payment_gateway"]').forEach((radio) => { radio.addEventListener('change', (e) => this.handlePaymentMethodChange(e)); }); if (typeof Stripe !== 'undefined') { this.initializeStripe(); } // Listen for unified booking submit event (new system) document.addEventListener('yatra_booking_submit', (event) => this.handleBookingSubmit(event)); // Listen for payment response for client-side Stripe payments document.addEventListener('yatra_payment_response', (event) => this.handlePaymentResponse(event)); // Legacy event support document.addEventListener('yatraBeforeBookingSubmit', (event) => this.handleGatewayIntercept(event)); } /** * Handle unified booking submit event */ handleBookingSubmit(event) { if (event.detail?.gateway !== 'stripe') { return; // Let other gateways handle } // Stripe intercepts the form submission to handle card payment event.preventDefault(); // Use the existing handleGatewayIntercept logic this.handleGatewayIntercept({ preventDefault: () => {}, detail: event.detail }); } /** * Handle payment response for Stripe (when server returns requires_action) */ handlePaymentResponse(event) { if (event.detail?.requires_action !== 'stripe_payment') { return; } this.handleStripePaymentAction(event.detail); } /** * Handle Stripe payment action from server response */ async handleStripePaymentAction(data) { try { // Load Stripe if not already loaded if (!this.stripe && data.publishable_key) { await this.loadStripeScript(); this.stripe = Stripe(data.publishable_key); } if (!this.stripe) { throw new Error('Stripe not initialized'); } const elements = this.stripe.elements({ clientSecret: data.client_secret }); // Create payment element in a modal const modal = document.createElement('div'); modal.id = 'yatra-stripe-modal'; modal.innerHTML = `

${__('Complete Your Payment', 'yatra')}

`; document.body.appendChild(modal); const paymentElement = elements.create('payment'); paymentElement.mount('#stripe-payment-element'); document.getElementById('stripe-submit-btn').addEventListener('click', async () => { const btn = document.getElementById('stripe-submit-btn'); btn.disabled = true; btn.textContent = __('Processing...', 'yatra'); // Append `stripe=success` to the gateway's return URL using proper URL // composition. The naive `+ '?stripe=success'` form breaks when // confirmation_url already carries query params (e.g. `?balance=paid` // from a remaining-balance flow) — two question marks produces an // invalid URL and browsers drop the second segment, losing the flag. let confirmReturnUrl = data.confirmation_url; try { const u = new URL(confirmReturnUrl, window.location.origin); u.searchParams.set('stripe', 'success'); confirmReturnUrl = u.toString(); } catch (_e) { // Fallback for malformed bases (shouldn't happen in practice) const join = confirmReturnUrl.includes('?') ? '&' : '?'; confirmReturnUrl = `${confirmReturnUrl}${join}stripe=success`; } const { error } = await this.stripe.confirmPayment({ elements, confirmParams: { return_url: confirmReturnUrl, }, }); if (error) { document.getElementById('stripe-error-message').textContent = error.message; document.getElementById('stripe-error-message').style.display = 'block'; btn.disabled = false; btn.textContent = `${__('Pay', 'yatra')} ${data.currency} ${parseFloat(data.amount).toFixed(2)}`; } }); document.getElementById('stripe-cancel-btn').addEventListener('click', () => { modal.remove(); document.dispatchEvent(new CustomEvent('yatra_payment_cancelled', { detail: { gateway: 'stripe' } })); }); } catch (error) { console.error('Stripe payment error:', error); document.dispatchEvent(new CustomEvent('yatra_payment_failed', { detail: { gateway: 'stripe', error: error.message } })); } } loadStripeScript() { return new Promise((resolve, reject) => { if (typeof Stripe !== 'undefined') { resolve(); return; } const script = document.createElement('script'); script.src = 'https://js.stripe.com/v3/'; script.onload = resolve; script.onerror = reject; document.head.appendChild(script); }); } addStripeElementsContainer() { const stripeOption = document.querySelector('input[name="payment_gateway"][value="stripe"]'); if (!stripeOption || document.querySelector('.yatra-stripe-container')) { return; } const container = document.createElement('div'); container.className = 'yatra-stripe-container'; const stripePreselected = !!stripeOption.checked; container.style.display = stripePreselected ? 'block' : 'none'; this.methodSwitcher = this.createMethodSwitcher(); // Show the "Payment methods" chooser only when there's an actual choice // (Card + Apple/Google Pay). With Card as the only enabled method the // chooser is redundant chrome, so present the card field directly. if (this.hasMultipleMethods) { container.appendChild(this.methodSwitcher); } else { container.classList.add('yatra-stripe-container--single-method'); } if (this.supportsPaymentRequest) { const requestWrapper = document.createElement('div'); requestWrapper.className = 'yatra-stripe-payment-request-wrapper'; const walletNote = window.yatraTranslations?.stripeWalletNote || __('Pay instantly with Apple Pay or Google Pay.', 'yatra'); const walletTitle = __('Apple Pay & Google Pay', 'yatra'); requestWrapper.innerHTML = `

${__('One-tap checkout', 'yatra')}

${walletTitle}

 Pay G Pay

${walletNote}

${__('Checking your device for wallet support…', 'yatra')}
`; container.appendChild(requestWrapper); this.walletWrapper = requestWrapper; this.walletWrapper.style.display = 'none'; } if (this.supportsCard) { const cardWrapper = document.createElement('div'); cardWrapper.className = 'yatra-stripe-card-wrapper'; cardWrapper.innerHTML = `
`; container.appendChild(cardWrapper); this.cardWrapper = cardWrapper; this.cardWrapper.style.display = 'none'; } const gatewayExtra = document.getElementById('yatra-gateway-extra-stripe'); if (gatewayExtra) { gatewayExtra.appendChild(container); } else { let parentElement = stripeOption.closest('.yatra-payment-option') || stripeOption.closest('.payment_method_stripe') || stripeOption.closest('.payment-method') || stripeOption.parentElement; if (parentElement) { parentElement.appendChild(container); } else { const paymentOptions = document.querySelector('.yatra-payment-options, .payment-methods'); if (paymentOptions && paymentOptions.parentNode) { paymentOptions.parentNode.insertBefore(container, paymentOptions.nextSibling); } } } this.setActiveMethod(this.getDefaultMethod()); this.syncMethodButtonsState(); if (stripePreselected && !this.stripe) { this.initializeStripe(); } } handlePaymentMethodChange(event) { const selectedMethod = event.target.value; const stripeContainer = document.querySelector('.yatra-stripe-container'); if (selectedMethod === 'stripe') { if (!this.stripe) { this.initializeStripe(); } else if (stripeContainer) { stripeContainer.style.display = 'block'; } } else if (stripeContainer) { stripeContainer.style.display = 'none'; } } initializeStripe() { const publishableKey = window.yatraBookingData?.stripe?.publishableKey || window.yatraBookingData?.stripe?.publishable_key; if (!publishableKey) { this.displayError(__('Stripe publishable key is missing. Please contact support.', 'yatra')); return; } if (typeof Stripe === 'undefined') { const script = document.createElement('script'); script.src = 'https://js.stripe.com/v3/'; script.onload = () => this.setupStripeElements(publishableKey); script.onerror = () => { this.displayError(__('Failed to load Stripe.js. Please try again or choose another payment method.', 'yatra')); }; document.head.appendChild(script); } else { this.setupStripeElements(publishableKey); } } setupStripeElements(publishableKey) { try { this.stripe = Stripe(publishableKey, { apiVersion: '2023-10-16' }); this.elements = this.stripe.elements({ fonts: [{ cssSrc: 'https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&display=swap' }], locale: 'auto' }); if (this.supportsCard) { const style = { base: { color: '#32325d', fontFamily: 'Inter, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif', fontSmoothing: 'antialiased', fontSize: '16px', '::placeholder': { color: '#aab7c4' } }, invalid: { color: '#e53935', iconColor: '#e53935' } }; this.cardElement = this.elements.create('card', { style, hidePostalCode: true }); const cardElement = document.getElementById('yatra-stripe-card-element'); if (cardElement) { this.cardElement.mount(cardElement); this.cardElement.on('change', (event) => this.displayError(event.error?.message || '')); } } this.initializePaymentRequestButton(); const stripeContainer = document.querySelector('.yatra-stripe-container'); if (stripeContainer && document.querySelector('input[name="payment_gateway"]:checked')?.value === 'stripe') { stripeContainer.style.display = 'block'; } } catch (error) { console.error('Error initializing Stripe:', error); this.displayError(__('Failed to initialize payment processor. Please try again or use another payment method.', 'yatra')); } } async handleGatewayIntercept(event) { const selectedMethod = document.querySelector('input[name="payment_gateway"]:checked')?.value; if (selectedMethod !== 'stripe') { return true; } event.preventDefault(); if (this.activeMethod && this.activeMethod !== 'card') { this.displayError(__('Please pay using the Apple Pay or Google Pay button above.', 'yatra')); return false; } if (!this.supportsCard) { this.displayError(__('Please use Apple Pay or Google Pay above to complete your payment.', 'yatra')); return false; } const bookingData = event?.detail?.bookingData || this.collectBookingData(); if (!bookingData) { this.displayError(__('Please complete the booking form before continuing.', 'yatra')); return false; } if (!this.stripe || !this.cardElement) { this.initializeStripe(); this.displayError(__('Stripe is still loading. Please wait a moment and try again.', 'yatra')); return false; } if (!this.originalButtonHtml && event?.detail?.originalBtnHtml) { this.originalButtonHtml = event.detail.originalBtnHtml; } else if (!this.originalButtonHtml && this.submitButton) { this.originalButtonHtml = this.submitButton.innerHTML; } this.displayError(''); this.setLoadingState(true); try { const paymentContext = await this.prepareStripePayment(bookingData, event?.detail?.form || this.paymentForm); const { bookingInfo, clientSecret, billingDetails, effectiveAmount } = paymentContext; const { error, paymentIntent } = await this.stripe.confirmCardPayment(clientSecret, { payment_method: { card: this.cardElement, billing_details: billingDetails }, return_url: this.buildReturnUrl(bookingInfo) }); if (error) { throw error; } if (paymentIntent.status === 'succeeded') { await this.notifyPaymentSuccess({ bookingId: bookingInfo.booking_id, transactionId: paymentIntent.id, saveCard: !!bookingData.save_card, amount: effectiveAmount, currency: bookingInfo.currency || bookingData.currency || 'USD' }); window.location.href = this.buildConfirmationUrlFromBookingInfo(bookingInfo); } else { throw new Error(__('Payment processing was not completed. Please check your payment details and try again.', 'yatra')); } } catch (error) { console.error('Stripe payment error:', error); this.displayError(error.message || __('Payment failed. Please try again or use another payment method.', 'yatra')); this.setLoadingState(false); } return false; } async initializePaymentRequestButton() { if (!this.supportsPaymentRequest || !this.stripe || this.paymentRequestMounted) { return; } try { this.paymentRequest = this.stripe.paymentRequest({ country: (this.companyCountry || 'US').toUpperCase(), currency: (window.yatraBookingData?.currency || 'USD').toLowerCase(), total: this.getPaymentRequestTotal(), requestPayerName: true, requestPayerEmail: true, }); const result = await this.paymentRequest.canMakePayment(); if (!result) { this.setWalletStatus( __('Apple Pay and Google Pay are not available on this browser/device.', 'yatra'), 'warning' ); this.updateWalletCapabilities(null); this.showWalletPlaceholder(__('Use card payment instead or switch to a supported device.', 'yatra')); this.syncMethodButtonsState(); return; } this.updateWalletCapabilities(result === true ? { applePay: true, googlePay: true } : result); this.setWalletStatus( __('Great! Apple Pay or Google Pay is available on this device.', 'yatra'), 'success' ); this.syncMethodButtonsState(); this.paymentRequest.on('paymentmethod', (event) => this.handlePaymentRequestPayment(event)); this.paymentRequestButton = this.elements.create('paymentRequestButton', { paymentRequest: this.paymentRequest, style: { paymentRequestButton: { type: 'default', theme: 'dark', height: '48px' } } }); const prContainer = document.getElementById('yatra-stripe-payment-request'); if (prContainer) { this.paymentRequestButton.mount(prContainer); this.paymentRequestMounted = true; prContainer.classList.remove('placeholder'); const stripeContainer = document.querySelector('.yatra-stripe-container'); if (stripeContainer && document.querySelector('input[name="payment_gateway"]:checked')?.value === 'stripe') { stripeContainer.style.display = 'block'; } } } catch (error) { console.error('Failed to initialize Stripe Payment Request button:', error); this.setWalletStatus( __('Unable to load Apple Pay / Google Pay. Please try again later or use card payment.', 'yatra'), 'error' ); this.showWalletPlaceholder(__('Wallet payment failed to load.', 'yatra')); this.updateWalletCapabilities(null); const errHint = __('Wallet payments could not be initialized. Use your card below, or try again after refreshing the page.', 'yatra'); this.walletMethodHints.google_pay = errHint; this.walletMethodHints.apple_pay = errHint; this.syncMethodButtonsState(); } } setWalletStatus(message, variant = 'info') { const statusEl = document.getElementById('yatra-stripe-wallet-status'); if (!statusEl) { return; } statusEl.textContent = message; statusEl.classList.remove('is-success', 'is-warning', 'is-error'); if (variant === 'success') { statusEl.classList.add('is-success'); } else if (variant === 'warning') { statusEl.classList.add('is-warning'); } else if (variant === 'error') { statusEl.classList.add('is-error'); } } showWalletPlaceholder(message) { const container = document.getElementById('yatra-stripe-payment-request'); if (!container) { return; } container.classList.add('wallet-placeholder'); container.innerHTML = `${message}`; } updateWalletCapabilities(result) { const availability = { apple_pay: false, google_pay: false }; if (result && typeof result === 'object') { availability.apple_pay = !!(result.applePay || result.apple_pay); availability.google_pay = !!(result.googlePay || result.google_pay); } else if (result === true) { availability.apple_pay = true; availability.google_pay = true; } this.walletCapabilities = availability; const hintGoogle = !availability.google_pay && this.enabledMethods.includes('google_pay') ? __('Google Pay is not available in this browser. Try Chrome or Edge on desktop or Android, use HTTPS, and ensure you have a card in your Google account.', 'yatra') : ''; const hintApple = !availability.apple_pay && this.enabledMethods.includes('apple_pay') ? __('Apple Pay is not available here. Use Safari on iPhone, iPad, or Mac, serve this page over HTTPS, and add your domain in Stripe for Apple Pay.', 'yatra') : ''; this.walletMethodHints = { google_pay: hintGoogle, apple_pay: hintApple, }; this.walletAvailabilityKnown = true; } createMethodSwitcher() { const wrapper = document.createElement('div'); wrapper.className = 'yatra-method-switcher'; const availableButtons = []; const header = document.createElement('div'); header.className = 'yatra-method-switcher__header'; const eyebrow = document.createElement('p'); eyebrow.className = 'yatra-label-eyebrow'; eyebrow.textContent = __('Choose a payment option', 'yatra'); const title = document.createElement('div'); title.className = 'yatra-method-switcher__title'; title.textContent = __('Payment methods', 'yatra'); const helper = document.createElement('p'); helper.className = 'yatra-method-switcher__helper'; helper.textContent = __('Pick the option that works best for you.', 'yatra'); header.appendChild(eyebrow); header.appendChild(title); header.appendChild(helper); if (this.supportsCard) { availableButtons.push({ id: 'card', label: __('Card', 'yatra'), description: __('Use any major debit or credit card via Stripe Elements.', 'yatra'), icon: this.getMethodIconMarkup('card') }); } if (this.enabledMethods.includes('google_pay')) { availableButtons.push({ id: 'google_pay', label: __('Google Pay', 'yatra'), description: __('Pay instantly with the cards saved to your Google account.', 'yatra'), icon: this.getMethodIconMarkup('google_pay') }); } if (this.enabledMethods.includes('apple_pay')) { availableButtons.push({ id: 'apple_pay', label: __('Apple Pay', 'yatra'), description: __('Use Apple Wallet on iPhone, iPad, or Mac to pay securely.', 'yatra'), icon: this.getMethodIconMarkup('apple_pay') }); } // Only a real choice (Card + a wallet) warrants the chooser. With Card // as the only enabled method, the render step drops this switcher and // shows the card field on its own (no redundant "Payment methods" row). this.hasMultipleMethods = availableButtons.length > 1; const buttons = document.createElement('div'); buttons.className = 'yatra-method-switcher__buttons'; availableButtons.forEach((method) => { this.methodHelpText[method.id] = method.description; const btn = document.createElement('button'); btn.type = 'button'; btn.className = 'yatra-method-button'; btn.dataset.method = method.id; btn.innerHTML = ` ${method.icon}
${method.label}
`; this.applyMethodTooltipText(btn.querySelector('.method-tooltip'), method.description); btn.addEventListener('click', () => { if (btn.getAttribute('aria-disabled') === 'true') { return; } this.setActiveMethod(method.id); }); this.methodButtons[method.id] = btn; buttons.appendChild(btn); }); wrapper.appendChild(header); wrapper.appendChild(buttons); return wrapper; } getMethodIconMarkup(type) { const base = ''; const icons = { card: `${base} ${close}`, google_pay: `${base} ${close}`, apple_pay: `${base} ${close}` }; return icons[type] || `${base}`; } getDefaultMethod() { if (this.supportsCard) { return 'card'; } if (this.enabledMethods.includes('google_pay')) { return 'google_pay'; } if (this.enabledMethods.includes('apple_pay')) { return 'apple_pay'; } return 'card'; } setActiveMethod(method) { if (!method) { return; } // Guard against selecting unsupported methods if (!this.isMethodSelectable(method)) { method = this.getDefaultMethod(); } if (this.activeMethod === method) { return; } this.activeMethod = method; if (this.cardWrapper) { this.cardWrapper.style.display = method === 'card' ? 'block' : 'none'; } if (this.walletWrapper) { const shouldShowWallet = method === 'google_pay' || method === 'apple_pay'; this.walletWrapper.style.display = shouldShowWallet ? 'block' : 'none'; } this.syncMethodButtonsState(); } syncMethodButtonsState() { Object.entries(this.methodButtons).forEach(([method, button]) => { if (!button) { return; } const isActive = method === this.activeMethod; button.classList.toggle('is-active', isActive); button.classList.remove('is-disabled', 'is-pending'); button.removeAttribute('aria-disabled'); const tooltipEl = button.querySelector('.method-tooltip'); const baseHelp = this.methodHelpText[method] || ''; if (this.isWalletMethod(method)) { if (!this.walletAvailabilityKnown) { button.classList.add('is-pending'); button.setAttribute('aria-disabled', 'true'); this.applyMethodTooltipText( tooltipEl, __('Checking if Google Pay or Apple Pay is available on this device…', 'yatra') ); } else { const isSupported = !!this.walletCapabilities[method]; if (!isSupported) { button.classList.add('is-disabled'); button.setAttribute('aria-disabled', 'true'); const why = this.walletMethodHints[method] || __('This wallet is not available on this device or browser. Use card payment instead.', 'yatra'); this.applyMethodTooltipText(tooltipEl, why); if (isActive) { this.setActiveMethod('card'); } } else { this.applyMethodTooltipText(tooltipEl, baseHelp); } } } else { this.applyMethodTooltipText(tooltipEl, baseHelp); } }); // Ensure at least one method is visible if everything else disabled const activeBtn = this.activeMethod ? this.methodButtons[this.activeMethod] : null; const activeBlocked = activeBtn && activeBtn.getAttribute('aria-disabled') === 'true'; if (!this.activeMethod || activeBlocked) { const fallback = this.supportsCard ? 'card' : (this.walletCapabilities.google_pay ? 'google_pay' : (this.walletCapabilities.apple_pay ? 'apple_pay' : null)); if (fallback) { this.setActiveMethod(fallback); } } } /** * @param {HTMLElement | null} el * @param {string} text */ applyMethodTooltipText(el, text) { if (!el) { return; } const t = String(text); el.setAttribute('data-tooltip', t); el.setAttribute('aria-label', t); el.setAttribute('title', t); } isWalletMethod(method) { return method === 'google_pay' || method === 'apple_pay'; } isMethodSelectable(method) { if (method === 'card') { return !!this.supportsCard; } if (this.isWalletMethod(method)) { if (!this.walletAvailabilityKnown) { return false; } return !!this.walletCapabilities[method]; } return false; } async handlePaymentRequestPayment(event) { try { const bookingData = this.collectBookingData(); if (!bookingData) { throw new Error(__('Please complete the booking form before using Apple Pay or Google Pay.', 'yatra')); } this.setLoadingState(true); const paymentContext = await this.prepareStripePayment(bookingData, this.paymentForm); const { bookingInfo, clientSecret, effectiveAmount } = paymentContext; const confirmation = await this.stripe.confirmCardPayment(clientSecret, { payment_method: event.paymentMethod.id, return_url: this.buildReturnUrl(bookingInfo) }); if (confirmation.error) { event.complete('fail'); throw confirmation.error; } event.complete('success'); if (confirmation.paymentIntent?.status === 'requires_action') { return; } if (confirmation.paymentIntent?.status === 'succeeded') { await this.notifyPaymentSuccess({ bookingId: bookingInfo.booking_id, transactionId: confirmation.paymentIntent.id, saveCard: !!bookingData.save_card, amount: effectiveAmount, currency: bookingInfo.currency || bookingData.currency || 'USD' }); } window.location.href = this.buildConfirmationUrlFromBookingInfo(bookingInfo); } catch (error) { console.error('Stripe payment request error:', error); if (event && typeof event.complete === 'function') { event.complete('fail'); } this.displayError(error.message || __('Payment failed. Please try again or use another payment method.', 'yatra')); this.setLoadingState(false); } } async prepareStripePayment(bookingData, formElement) { // /booking/create requires BOTH the standard WP REST nonce // (X-WP-Nonce) AND a booking-scoped action nonce // (X-Yatra-Booking-Nonce). The booking-scoped one is what // gates the actual write; the endpoint's public // permission_callback intentionally bypasses WP's default // cookie/nonce check so guest checkouts work, and falls back // to the booking-scoped nonce as the real CSRF defence. // Missing it returns { code: 'invalid_nonce' } 403. const bookingNonce = (typeof window !== 'undefined' && window.yatraBookingData && window.yatraBookingData.bookingNonce) || (typeof document !== 'undefined' ? (document.querySelector('input[name="yatra_booking_nonce"]') || {}).value : '') || ''; // Always call the same endpoint - server decides based on session type const bookingResponse = await fetch(`${this.apiUrl}/booking/create`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-WP-Nonce': this.nonce, 'X-Yatra-Booking-Nonce': bookingNonce }, body: JSON.stringify(bookingData) }); const bookingResult = await bookingResponse.json(); if (!bookingResult.success) { throw new Error(bookingResult.message || __('Failed to process request. Please try again.', 'yatra')); } const bookingInfo = bookingResult.data; const effectiveAmount = this.getEffectiveAmount(bookingData, bookingInfo); if (bookingInfo.client_secret) { const customerEmail = bookingInfo.customer_email || bookingData.contact_email || bookingData.email; const customerName = bookingInfo.customer_name || [bookingData.contact_first_name, bookingData.contact_last_name] .filter(Boolean) .join(' ') || bookingData.full_name; if (!customerEmail) { throw new Error(__('Email address is required.', 'yatra')); } const billingDetails = this.getBillingDetails(bookingData, formElement); return { bookingInfo, clientSecret: bookingInfo.client_secret, billingDetails, effectiveAmount, }; } const customerEmail = bookingInfo.customer_email || bookingData.contact_email || bookingData.email; const customerName = bookingInfo.customer_name || [bookingData.contact_first_name, bookingData.contact_last_name] .filter(Boolean) .join(' ') || bookingData.full_name; if (!customerEmail) { throw new Error(__('Email address is required.', 'yatra')); } const paymentIntentResponse = await fetch(`${this.apiUrl}/payment/create-intent`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-WP-Nonce': this.nonce }, body: JSON.stringify({ gateway: 'stripe', booking_id: bookingInfo.booking_id, amount: effectiveAmount, currency: bookingInfo.currency, trip_id: bookingInfo.trip_id || bookingData.trip_id || null, trip_date: bookingInfo.trip_date || bookingData.travel_date || '', customer_email: customerEmail, customer_name: customerName, return_url: this.buildReturnUrl(bookingInfo) }) }); const intentPayload = await paymentIntentResponse.json(); if (!paymentIntentResponse.ok) { throw new Error(intentPayload?.message || __('Unable to process payment. Please try again.', 'yatra')); } const clientSecret = intentPayload.client_secret; if (!clientSecret) { throw new Error(intentPayload?.error?.message || __('Unable to process payment. Please try again.', 'yatra')); } const billingDetails = this.getBillingDetails(bookingData, formElement); return { bookingInfo, clientSecret, billingDetails, effectiveAmount, }; } getBillingDetails(bookingData, formElement) { const getField = (keys) => { const candidates = Array.isArray(keys) ? keys : [keys]; for (const key of candidates) { if (bookingData && bookingData[key]) { return bookingData[key]; } if (formElement) { const input = formElement.querySelector(`[name="${key}"]`); if (input && input.value) { return input.value; } } } return ''; }; const firstName = getField(['billing_first_name', 'contact_first_name', 'first_name']); const lastName = getField(['billing_last_name', 'contact_last_name', 'last_name']); const email = getField(['billing_email', 'contact_email', 'email']); const phone = getField(['billing_phone', 'contact_phone', 'phone']); const line1 = getField(['billing_address_1', 'contact_address']); const city = getField(['billing_city', 'contact_city']); const country = getField(['billing_country', 'contact_country']); const postalCode = getField(['billing_postcode', 'contact_postcode', 'contact_zip']); const billingDetails = {}; const name = `${firstName} ${lastName}`.trim(); if (name) { billingDetails.name = name; } if (email) { billingDetails.email = email; } if (phone) { billingDetails.phone = phone; } const address = {}; if (line1) { address.line1 = line1; } if (city) { address.city = city; } if (country) { const normalizedCountry = country.trim().toUpperCase(); if (normalizedCountry.length === 2) { address.country = normalizedCountry; } } if (postalCode) { address.postal_code = postalCode; } if (Object.keys(address).length > 0) { billingDetails.address = address; } return billingDetails; } collectBookingData() { if (!this.paymentForm) { return null; } const formData = new FormData(this.paymentForm); const bookingData = {}; for (const [key, value] of formData.entries()) { const matches = key.match(/^([^\[]+)(?:\[([^\]]*)\])?(?:\[([^\]]*)\])?$/); if (matches) { const [, base, index, field] = matches; if (index !== undefined && field !== undefined) { bookingData[base] = bookingData[base] || {}; bookingData[base][index] = bookingData[base][index] || {}; bookingData[base][index][field] = value; } else if (index !== undefined) { bookingData[base] = bookingData[base] || []; bookingData[base][index] = value; } else { bookingData[key] = value; } } else { bookingData[key] = value; } } bookingData.accept_terms = this.paymentForm.querySelector('input[name="accept_terms"]')?.checked || false; bookingData.accept_privacy = this.paymentForm.querySelector('input[name="accept_privacy"]')?.checked || false; bookingData.subscribe_newsletter = this.paymentForm.querySelector('input[name="subscribe_newsletter"]')?.checked || false; bookingData.payment_due = parseFloat(this.paymentForm.getAttribute('data-payment-due')) || window.yatraBookingData?.paymentDue || null; bookingData.currency = window.yatraBookingData?.currency || bookingData.currency || this.paymentForm.querySelector('input[name="currency"]')?.value || 'USD'; bookingData.payment_gateway = 'stripe'; return bookingData; } displayError(message) { const errorElement = document.getElementById('yatra-stripe-card-errors'); if (errorElement) { errorElement.textContent = message || ''; errorElement.style.display = message ? 'block' : 'none'; if (message) { errorElement.scrollIntoView({ behavior: 'smooth', block: 'center' }); } } else if (message) { console.error(message); } } setLoadingState(isLoading) { if (!this.submitButton) { return; } if (isLoading) { this.submitButton.disabled = true; // Spinner markup is fixed; only the user-visible text is // wrapped so it ends up in the .pot with a clean msgid. this.submitButton.innerHTML = ' ' + __('Processing Payment...', 'yatra'); } else { this.submitButton.disabled = false; this.submitButton.innerHTML = this.originalButtonHtml; } } getEffectiveAmount(bookingData, bookingInfo) { // After /booking/create, server-calculated charge (deposit / partial / full) is authoritative. // `amount` = charge for this checkout (matches gateway). Never fall back to `total_amount` (full order). const serverFirst = [ parseFloat(bookingInfo?.amount), parseFloat(bookingInfo?.amount_due) ]; for (let i = 0; i < serverFirst.length; i++) { const v = serverFirst[i]; if (!isNaN(v) && v > 0) { return v; } } const summaryDue = parseFloat(window.yatraBookingSummary?.amountDue); if (!isNaN(summaryDue) && summaryDue > 0) { return summaryDue; } const candidates = [ parseFloat(bookingData?.amount_due), parseFloat(bookingData?.payment_due), parseFloat(this.paymentForm?.dataset.paymentDue), parseFloat(window.yatraBookingData?.paymentDue) ].map((val) => (isNaN(val) ? 0 : val)); const picked = candidates.find((val) => val > 0) ?? 0; return picked > 0 ? picked : 0; } getCurrentPaymentDue() { const summaryDue = parseFloat(window.yatraBookingSummary?.amountDue); if (!isNaN(summaryDue) && summaryDue > 0) { return summaryDue; } const candidates = [ parseFloat(this.paymentForm?.dataset.paymentDue), parseFloat(window.yatraBookingData?.paymentDue) ].map((val) => (isNaN(val) ? 0 : val)); return candidates.find((val) => val > 0) ?? 0; } getPaymentRequestTotal() { return { label: this.tripTitle, amount: this.getAmountInCents(this.getCurrentPaymentDue()) }; } getAmountInCents(amount) { const value = isNaN(amount) ? 0 : amount; return Math.max(50, Math.round(value * 100)); } /** * Pageless confirmation URLs use the same base as Settings → Default Booking URL Base * (e.g. book → /book/confirmation/{reference}/). Never hard-code /booking-confirmation/. */ buildConfirmationUrlFromBookingInfo(bookingInfo) { if (bookingInfo.redirect_url) { return bookingInfo.redirect_url; } const d = window.yatraBookingData || {}; const site = (d.siteUrl || window.location.origin || '').replace(/\/$/, ''); const ref = String(bookingInfo.reference || '').trim(); const id = bookingInfo.booking_id; const segment = ref || (id != null && id !== '' ? String(id) : ''); if (!segment) { return `${site}/`; } if (d.permalinkStructure === 'plain') { return `${site}/?yatra_booking_confirmation=${encodeURIComponent(segment)}`; } const bb = String(d.bookingBase || 'book').replace(/^\/|\/$/g, ''); return `${site}/${bb}/confirmation/${encodeURIComponent(segment)}/`; } buildReturnUrl(bookingInfo) { const base = this.buildConfirmationUrlFromBookingInfo(bookingInfo); try { const url = new URL(base, window.location.origin); url.searchParams.set('stripe_return', '1'); return url.toString(); } catch (e) { const join = base.includes('?') ? '&' : '?'; return `${base}${join}stripe_return=1`; } } async notifyPaymentSuccess({ bookingId, transactionId, saveCard, amount, currency }) { try { const response = await fetch(`${this.apiUrl}/payment/confirm`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-WP-Nonce': this.nonce, }, body: JSON.stringify({ gateway: 'stripe', booking_id: bookingId, transaction_id: transactionId, save_card: !!saveCard, amount, currency, }), }); const payload = await response.json(); if (!response.ok || payload?.success === false) { throw new Error(payload?.message || __('Failed to record payment.', 'yatra')); } return payload; } catch (error) { console.error('Failed to notify backend about payment success:', error); throw error; } } } document.addEventListener('DOMContentLoaded', () => { if (document.getElementById('yatra-booking-form')) { const apiUrl = window.yatraBookingData?.apiUrl || '/wp-json/yatra/v1'; const nonce = window.yatraBookingData?.nonce || ''; new YatraStripe(apiUrl, nonce); } });