diff --git a/frontend/src/proprietary/components/shared/StripeCheckout.tsx b/frontend/src/proprietary/components/shared/StripeCheckout.tsx index 7e1cb43f2..b421417aa 100644 --- a/frontend/src/proprietary/components/shared/StripeCheckout.tsx +++ b/frontend/src/proprietary/components/shared/StripeCheckout.tsx @@ -5,6 +5,7 @@ import { loadStripe } from '@stripe/stripe-js'; import { EmbeddedCheckoutProvider, EmbeddedCheckout } from '@stripe/react-stripe-js'; import licenseService, { PlanTierGroup } from '@app/services/licenseService'; import { Z_INDEX_OVER_CONFIG_MODAL } from '@app/styles/zIndex'; +import { pollLicenseKeyWithBackoff, activateLicenseKey } from '@app/utils/licenseCheckoutUtils'; // Validate Stripe key (static validation, no dynamic imports) const STRIPE_KEY = import.meta.env.VITE_STRIPE_PUBLISHABLE_KEY; @@ -108,10 +109,17 @@ const StripeCheckout: React.FC = ({ current_license_key: existingLicenseKey, requires_seats: selectedPlan.requiresSeats, seat_count: Math.max(1, Math.min(minimumSeats || 1, 10000)), - successUrl: `${window.location.origin}/settings/adminPlan?session_id={CHECKOUT_SESSION_ID}`, - cancelUrl: `${window.location.origin}/settings/adminPlan`, }); + // Check if we got a redirect URL (hosted checkout for HTTP) + if (response.url) { + console.log('Redirecting to Stripe hosted checkout:', response.url); + // Redirect to Stripe's hosted checkout page + window.location.href = response.url; + return; + } + + // Otherwise, use embedded checkout (HTTPS) setState({ status: 'ready', clientSecret: response.clientSecret, @@ -129,102 +137,29 @@ const StripeCheckout: React.FC = ({ }; const pollForLicenseKey = useCallback(async (installId: string) => { - // Exponential backoff: 1s → 2s → 4s → 8s → 16s (31 seconds total, 5 requests) - const BACKOFF_MS = [1000, 2000, 4000, 8000, 16000]; - let attemptIndex = 0; + // Use shared polling utility + const result = await pollLicenseKeyWithBackoff(installId, { + isMounted: () => isMountedRef.current, + onStatusChange: setPollingStatus, + }); - setPollingStatus('polling'); - console.log(`Starting license key polling for installation: ${installId}`); + if (result.success && result.licenseKey) { + setLicenseKey(result.licenseKey); - const poll = async (): Promise => { - // Check if component is still mounted - if (!isMountedRef.current) { - console.log('Polling cancelled: component unmounted'); - return; + // Activate the license key + const activation = await activateLicenseKey(result.licenseKey, { + isMounted: () => isMountedRef.current, + onActivated: onLicenseActivated, + }); + + if (!activation.success) { + console.error('Failed to activate license key:', activation.error); } - - const attemptNumber = attemptIndex + 1; - console.log(`Polling attempt ${attemptNumber}/${BACKOFF_MS.length}`); - - try { - const response = await licenseService.checkLicenseKey(installId); - - // Check mounted after async operation - if (!isMountedRef.current) return; - - if (response.status === 'ready' && response.license_key) { - console.log('✅ License key ready!'); - setLicenseKey(response.license_key); - setPollingStatus('ready'); - - // Save license key to backend - try { - const saveResponse = await licenseService.saveLicenseKey(response.license_key); - if (!isMountedRef.current) return; - - if (saveResponse.success) { - console.log(`License key activated on backend: ${saveResponse.licenseType}`); - - // Fetch and pass license info to parent - try { - const licenseInfo = await licenseService.getLicenseInfo(); - if (!isMountedRef.current) return; - onLicenseActivated?.(licenseInfo); - } catch (infoError) { - console.error('Error fetching license info:', infoError); - } - } else { - console.error('Failed to save license key to backend:', saveResponse.error); - } - } catch (error) { - console.error('Error saving license key to backend:', error); - } - - return; - } - - // License not ready yet, continue polling - attemptIndex++; - - if (attemptIndex >= BACKOFF_MS.length) { - console.warn('⏱️ License polling timeout after all attempts'); - if (!isMountedRef.current) return; - setPollingStatus('timeout'); - return; - } - - // Schedule next poll with exponential backoff - const nextDelay = BACKOFF_MS[attemptIndex]; - console.log(`Retrying in ${nextDelay}ms...`); - - pollingTimeoutRef.current = setTimeout(() => { - poll(); - }, nextDelay); - - } catch (error) { - console.error(`Polling attempt ${attemptNumber} failed:`, error); - - if (!isMountedRef.current) return; - - attemptIndex++; - - if (attemptIndex >= BACKOFF_MS.length) { - console.error('Polling failed after all attempts'); - setPollingStatus('timeout'); - return; - } - - // Retry with exponential backoff even on error - const nextDelay = BACKOFF_MS[attemptIndex]; - console.log(`Retrying after error in ${nextDelay}ms...`); - - pollingTimeoutRef.current = setTimeout(() => { - poll(); - }, nextDelay); - } - }; - - await poll(); + } else if (result.timedOut) { + console.warn('License key polling timed out'); + } else if (result.error) { + console.error('License key polling failed:', result.error); + } }, [onLicenseActivated]); const handlePaymentComplete = async () => { @@ -237,26 +172,16 @@ const StripeCheckout: React.FC = ({ console.log('Upgrade detected - syncing existing license key'); setPollingStatus('polling'); - try { - const saveResponse = await licenseService.saveLicenseKey(currentLicenseKey); + const activation = await activateLicenseKey(currentLicenseKey, { + isMounted: () => true, // Modal is open, no need to check + onActivated: onLicenseActivated, + }); - if (saveResponse.success) { - console.log(`License upgraded successfully: ${saveResponse.licenseType}`); - setPollingStatus('ready'); - - // Fetch and pass updated license info to parent - try { - const licenseInfo = await licenseService.getLicenseInfo(); - onLicenseActivated?.(licenseInfo); - } catch (infoError) { - console.error('Error fetching updated license info:', infoError); - } - } else { - console.error('Failed to sync upgraded license:', saveResponse.error); - setPollingStatus('timeout'); - } - } catch (error) { - console.error('Error syncing upgraded license:', error); + if (activation.success) { + console.log(`License upgraded successfully: ${activation.licenseType}`); + setPollingStatus('ready'); + } else { + console.error('Failed to sync upgraded license:', activation.error); setPollingStatus('timeout'); } diff --git a/frontend/src/proprietary/components/shared/config/configSections/AdminPlanSection.tsx b/frontend/src/proprietary/components/shared/config/configSections/AdminPlanSection.tsx index 86d4dac54..31ca06fac 100644 --- a/frontend/src/proprietary/components/shared/config/configSections/AdminPlanSection.tsx +++ b/frontend/src/proprietary/components/shared/config/configSections/AdminPlanSection.tsx @@ -15,6 +15,7 @@ import { useAdminSettings } from '@app/hooks/useAdminSettings'; import PendingBadge from '@app/components/shared/config/PendingBadge'; import { Z_INDEX_OVER_CONFIG_MODAL } from '@app/styles/zIndex'; import { ManageBillingButton } from '@app/components/shared/ManageBillingButton'; +import { pollLicenseKeyWithBackoff, activateLicenseKey } from '@app/utils/licenseCheckoutUtils'; interface PremiumSettingsData { key?: string; @@ -61,6 +62,107 @@ const AdminPlanSection: React.FC = () => { } }; + // Handle return from hosted Stripe checkout + const handleCheckoutReturn = async () => { + const urlParams = new URLSearchParams(window.location.search); + const paymentStatus = urlParams.get('payment_status'); + const sessionId = urlParams.get('session_id'); + + if (paymentStatus === 'success' && sessionId) { + console.log('Payment successful via hosted checkout:', sessionId); + + // Clear URL parameters + window.history.replaceState({}, '', window.location.pathname); + + // Check if this is an upgrade or new subscription + if (currentLicenseInfo?.licenseKey) { + // UPGRADE: Sync existing license key + console.log('Upgrade detected - syncing existing license'); + + const activation = await activateLicenseKey(currentLicenseInfo.licenseKey, { + onActivated: fetchLicenseInfo, + }); + + if (activation.success) { + alert({ + message: t('payment.upgradeSuccess', 'Your subscription has been upgraded successfully!'), + color: 'green', + }); + } else { + console.error('Failed to sync license after upgrade:', activation.error); + alert({ + message: t('payment.syncError', 'Payment successful but license sync failed. Please contact support.'), + color: 'red', + }); + } + } else { + // NEW SUBSCRIPTION: Poll for license key + console.log('New subscription - polling for license key'); + alert({ + message: t('payment.paymentSuccess', 'Payment successful! Retrieving your license key...'), + color: 'green', + }); + + try { + const installationId = await licenseService.getInstallationId(); + console.log('Polling for license key with installation ID:', installationId); + + // Use shared polling utility + const result = await pollLicenseKeyWithBackoff(installationId); + + if (result.success && result.licenseKey) { + // Activate the license key + const activation = await activateLicenseKey(result.licenseKey, { + onActivated: fetchLicenseInfo, + }); + + if (activation.success) { + console.log(`License key activated: ${activation.licenseType}`); + alert({ + message: t('payment.licenseActivated', 'License key activated successfully!'), + color: 'green', + }); + } else { + console.error('Failed to save license key:', activation.error); + alert({ + message: t('payment.licenseSaveError', 'Failed to save license key. Please contact support.'), + color: 'red', + }); + } + } else if (result.timedOut) { + console.warn('License key polling timed out'); + alert({ + message: t('payment.licenseDelayed', 'License key is being generated. Please check back shortly or contact support.'), + color: 'yellow', + }); + } else { + console.error('License key polling failed:', result.error); + alert({ + message: t('payment.licensePollingError', 'Failed to retrieve license key. Please check your email or contact support.'), + color: 'red', + }); + } + } catch (error) { + console.error('Failed to poll for license key:', error); + alert({ + message: t('payment.licenseRetrievalError', 'Failed to retrieve license key. Please check your email or contact support.'), + color: 'red', + }); + } + } + } else if (paymentStatus === 'canceled') { + console.log('Payment canceled by user'); + + // Clear URL parameters + window.history.replaceState({}, '', window.location.pathname); + + alert({ + message: t('payment.paymentCanceled', 'Payment was canceled.'), + color: 'yellow', + }); + } + }; + // Check if Stripe is configured const stripeKey = import.meta.env.VITE_STRIPE_PUBLISHABLE_KEY; if (!stripeKey || error) { @@ -68,6 +170,9 @@ const AdminPlanSection: React.FC = () => { } fetchLicenseInfo(); + // Handle checkout return after license info is loaded + handleCheckoutReturn(); + // Fetch premium settings fetchPremiumSettings(); }, [error, config]); diff --git a/frontend/src/proprietary/services/licenseService.ts b/frontend/src/proprietary/services/licenseService.ts index c58d36b01..5e4920092 100644 --- a/frontend/src/proprietary/services/licenseService.ts +++ b/frontend/src/proprietary/services/licenseService.ts @@ -1,5 +1,6 @@ import apiClient from '@app/services/apiClient'; import { supabase } from '@app/services/supabaseClient'; +import { getCheckoutMode } from '@app/utils/protocolDetection'; export interface PlanFeature { name: string; @@ -48,6 +49,7 @@ export interface CheckoutSessionRequest { export interface CheckoutSessionResponse { clientSecret: string; sessionId: string; + url?: string; // URL for hosted checkout (when not using HTTPS) } export interface BillingPortalResponse { @@ -356,6 +358,11 @@ const licenseService = { * Create a Stripe checkout session for upgrading */ async createCheckoutSession(request: CheckoutSessionRequest): Promise { + // Detect if HTTPS is available to determine checkout mode + const checkoutMode = getCheckoutMode(); + const baseUrl = window.location.origin; + const settingsUrl = `${baseUrl}/settings/adminPlan`; + const { data, error } = await supabase.functions.invoke('create-checkout', { body: { self_hosted: true, @@ -364,7 +371,15 @@ const licenseService = { current_license_key: request.current_license_key, requires_seats: request.requires_seats, seat_count: request.seat_count || 1, - callback_base_url: window.location.origin, + callback_base_url: baseUrl, + ui_mode: checkoutMode, + // For hosted checkout, provide success/cancel URLs + success_url: checkoutMode === 'hosted' + ? `${settingsUrl}?session_id={CHECKOUT_SESSION_ID}&payment_status=success` + : undefined, + cancel_url: checkoutMode === 'hosted' + ? `${settingsUrl}?payment_status=canceled` + : undefined, }, }); diff --git a/frontend/src/proprietary/utils/licenseCheckoutUtils.ts b/frontend/src/proprietary/utils/licenseCheckoutUtils.ts new file mode 100644 index 000000000..f3090de2e --- /dev/null +++ b/frontend/src/proprietary/utils/licenseCheckoutUtils.ts @@ -0,0 +1,199 @@ +/** + * Shared utilities for license checkout completion + * Used by both embedded and hosted checkout flows + */ + +import licenseService, { LicenseInfo } from '@app/services/licenseService'; + +/** + * Result of license key polling + */ +export interface LicenseKeyPollResult { + success: boolean; + licenseKey?: string; + error?: string; + timedOut?: boolean; +} + +/** + * Configuration for license key polling + */ +export interface PollConfig { + /** Check if component is still mounted (prevents state updates after unmount) */ + isMounted?: () => boolean; + /** Callback for status changes during polling */ + onStatusChange?: (status: 'polling' | 'ready' | 'timeout') => void; + /** Custom backoff intervals in milliseconds (default: [1000, 2000, 4000, 8000, 16000]) */ + backoffMs?: number[]; +} + +/** + * Poll for license key with exponential backoff + * Consolidates polling logic used by both embedded and hosted checkout + */ +export async function pollLicenseKeyWithBackoff( + installationId: string, + config: PollConfig = {} +): Promise { + const { + isMounted = () => true, + onStatusChange, + backoffMs = [1000, 2000, 4000, 8000, 16000], + } = config; + + let attemptIndex = 0; + + onStatusChange?.('polling'); + console.log(`Starting license key polling for installation: ${installationId}`); + + const poll = async (): Promise => { + // Check if component is still mounted + if (!isMounted()) { + console.log('Polling cancelled: component unmounted'); + return { success: false, error: 'Component unmounted' }; + } + + const attemptNumber = attemptIndex + 1; + console.log(`Polling attempt ${attemptNumber}/${backoffMs.length}`); + + try { + const response = await licenseService.checkLicenseKey(installationId); + + // Check mounted after async operation + if (!isMounted()) { + return { success: false, error: 'Component unmounted' }; + } + + if (response.status === 'ready' && response.license_key) { + console.log('✅ License key ready!'); + onStatusChange?.('ready'); + return { + success: true, + licenseKey: response.license_key, + }; + } + + // License not ready yet, continue polling + attemptIndex++; + + if (attemptIndex >= backoffMs.length) { + console.warn('⏱️ License polling timeout after all attempts'); + onStatusChange?.('timeout'); + return { + success: false, + timedOut: true, + error: 'Polling timeout - license key not ready', + }; + } + + // Wait before next attempt + const nextDelay = backoffMs[attemptIndex]; + console.log(`Retrying in ${nextDelay}ms...`); + await new Promise(resolve => setTimeout(resolve, nextDelay)); + + return poll(); + } catch (error) { + console.error(`Polling attempt ${attemptNumber} failed:`, error); + + if (!isMounted()) { + return { success: false, error: 'Component unmounted' }; + } + + attemptIndex++; + + if (attemptIndex >= backoffMs.length) { + console.error('Polling failed after all attempts'); + onStatusChange?.('timeout'); + return { + success: false, + error: error instanceof Error ? error.message : 'Polling failed', + }; + } + + // Retry with exponential backoff even on error + const nextDelay = backoffMs[attemptIndex]; + console.log(`Retrying after error in ${nextDelay}ms...`); + await new Promise(resolve => setTimeout(resolve, nextDelay)); + + return poll(); + } + }; + + return poll(); +} + +/** + * Result of license key activation + */ +export interface LicenseActivationResult { + success: boolean; + licenseType?: string; + licenseInfo?: LicenseInfo; + error?: string; +} + +/** + * Activate a license key by saving it to the backend and fetching updated info + * Consolidates activation logic used by both embedded and hosted checkout + */ +export async function activateLicenseKey( + licenseKey: string, + options: { + /** Check if component is still mounted */ + isMounted?: () => boolean; + /** Callback when license is activated with updated info */ + onActivated?: (licenseInfo: LicenseInfo) => void; + } = {} +): Promise { + const { isMounted = () => true, onActivated } = options; + + try { + console.log('Activating license key...'); + const saveResponse = await licenseService.saveLicenseKey(licenseKey); + + if (!isMounted()) { + return { success: false, error: 'Component unmounted' }; + } + + if (saveResponse.success) { + console.log(`License key activated: ${saveResponse.licenseType}`); + + // Fetch updated license info + try { + const licenseInfo = await licenseService.getLicenseInfo(); + + if (!isMounted()) { + return { success: false, error: 'Component unmounted' }; + } + + onActivated?.(licenseInfo); + + return { + success: true, + licenseType: saveResponse.licenseType, + licenseInfo, + }; + } catch (infoError) { + console.error('Error fetching license info after activation:', infoError); + // Still return success since save succeeded + return { + success: true, + licenseType: saveResponse.licenseType, + error: 'Failed to fetch updated license info', + }; + } + } else { + console.error('Failed to save license key:', saveResponse.error); + return { + success: false, + error: saveResponse.error || 'Failed to save license key', + }; + } + } catch (error) { + console.error('Error activating license key:', error); + return { + success: false, + error: error instanceof Error ? error.message : 'Activation failed', + }; + } +} diff --git a/frontend/src/proprietary/utils/protocolDetection.ts b/frontend/src/proprietary/utils/protocolDetection.ts new file mode 100644 index 000000000..1f7ff52d9 --- /dev/null +++ b/frontend/src/proprietary/utils/protocolDetection.ts @@ -0,0 +1,43 @@ +/** + * Protocol detection utility for determining secure context + * Used to decide between Embedded Checkout (HTTPS) and Hosted Checkout (HTTP) + */ + +/** + * Check if the current context is secure (HTTPS or localhost) + * @returns true if HTTPS or localhost, false if HTTP + */ +export function isSecureContext(): boolean { + // Allow localhost for development (works with both HTTP and HTTPS) + if (typeof window !== 'undefined') { + const hostname = window.location.hostname; + const protocol = window.location.protocol; + + // Localhost is considered secure for development + // if (hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '[::1]') { + // return true; + // } + + // Check if HTTPS + return protocol === 'https:'; + } + + // Default to false if window is not available (SSR context) + return false; +} + +/** + * Get the appropriate Stripe checkout UI mode based on current context + * @returns 'embedded' for HTTPS/localhost, 'hosted' for HTTP + */ +export function getCheckoutMode(): 'embedded' | 'hosted' { + return isSecureContext() ? 'embedded' : 'hosted'; +} + +/** + * Check if Embedded Checkout can be used in current context + * @returns true if secure context (HTTPS/localhost) + */ +export function canUseEmbeddedCheckout(): boolean { + return isSecureContext(); +}