mirror of
https://github.com/arsvendg/Stirling-PDF.git
synced 2026-09-13 12:15:29 +02:00
Stripe and license payment integration (#4935)
selfhosted stripe payment and license integration --------- Co-authored-by: Anthony Stirling <[email protected]> Co-authored-by: Connor Yoh <[email protected]>
This commit is contained in:
co-authored by
Anthony Stirling
Connor Yoh
parent
f4725b98b0
commit
8d9e70c796
@@ -0,0 +1,265 @@
|
||||
/**
|
||||
* 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
|
||||
* Used for NEW subscriptions where we have a new license key to save
|
||||
*/
|
||||
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',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resync existing license with Keygen
|
||||
* Used for UPGRADES where we already have a license key configured
|
||||
* Calls the dedicated resync endpoint instead of re-saving the same key
|
||||
*/
|
||||
export async function resyncExistingLicense(
|
||||
options: {
|
||||
/** Check if component is still mounted */
|
||||
isMounted?: () => boolean;
|
||||
/** Callback when license is resynced with updated info */
|
||||
onActivated?: (licenseInfo: LicenseInfo) => void;
|
||||
} = {}
|
||||
): Promise<LicenseActivationResult> {
|
||||
const { isMounted = () => true, onActivated } = options;
|
||||
|
||||
try {
|
||||
console.log('Resyncing existing license with Keygen...');
|
||||
const resyncResponse = await licenseService.resyncLicense();
|
||||
|
||||
if (!isMounted()) {
|
||||
return { success: false, error: 'Component unmounted' };
|
||||
}
|
||||
|
||||
if (resyncResponse.success) {
|
||||
console.log(`License resynced: ${resyncResponse.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: resyncResponse.licenseType,
|
||||
licenseInfo,
|
||||
};
|
||||
} catch (infoError) {
|
||||
console.error('Error fetching license info after resync:', infoError);
|
||||
// Still return success since resync succeeded
|
||||
return {
|
||||
success: true,
|
||||
licenseType: resyncResponse.licenseType,
|
||||
error: 'Failed to fetch updated license info',
|
||||
};
|
||||
}
|
||||
} else {
|
||||
console.error('Failed to resync license:', resyncResponse.error);
|
||||
return {
|
||||
success: false,
|
||||
error: resyncResponse.error || 'Failed to resync license',
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error resyncing license:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Resync failed',
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
Reference in New Issue
Block a user