redirect payments when http

This commit is contained in:
Connor Yoh
2025-11-19 11:25:40 +00:00
parent 85ad9017d6
commit 920391f38a
5 changed files with 402 additions and 115 deletions

View File

@@ -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<StripeCheckoutProps> = ({
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<StripeCheckoutProps> = ({
};
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<void> => {
// 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<StripeCheckoutProps> = ({
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');
}

View File

@@ -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]);

View File

@@ -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<CheckoutSessionResponse> {
// 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,
},
});

View File

@@ -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<LicenseKeyPollResult> {
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<LicenseKeyPollResult> => {
// 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<LicenseActivationResult> {
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',
};
}
}

View File

@@ -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();
}