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:
ConnorYoh
2025-11-20 12:07:37 +00:00
committed by GitHub
co-authored by Anthony Stirling Connor Yoh
parent f4725b98b0
commit 8d9e70c796
25 changed files with 3553 additions and 28 deletions
@@ -0,0 +1,350 @@
import React, { createContext, useContext, useState, useCallback, useEffect, ReactNode } from 'react';
import { useTranslation } from 'react-i18next';
import { usePlans } from '@app/hooks/usePlans';
import licenseService, { PlanTierGroup, LicenseInfo, mapLicenseToTier } from '@app/services/licenseService';
import StripeCheckout from '@app/components/shared/StripeCheckout';
import { userManagementService } from '@app/services/userManagementService';
import { alert } from '@app/components/toast';
import { pollLicenseKeyWithBackoff, activateLicenseKey, resyncExistingLicense } from '@app/utils/licenseCheckoutUtils';
import { useLicense } from '@app/contexts/LicenseContext';
import { isSupabaseConfigured } from '@app/services/supabaseClient';
export interface CheckoutOptions {
minimumSeats?: number; // Override calculated seats for enterprise
currency?: string; // Optional currency override (defaults to 'gbp')
onSuccess?: (sessionId: string) => void; // Callback after successful payment
onError?: (error: string) => void; // Callback on error
}
interface CheckoutContextValue {
openCheckout: (
tier: 'server' | 'enterprise',
options?: CheckoutOptions
) => Promise<void>;
closeCheckout: () => void;
isOpen: boolean;
isLoading: boolean;
}
const CheckoutContext = createContext<CheckoutContextValue | undefined>(undefined);
interface CheckoutProviderProps {
children: ReactNode;
defaultCurrency?: string;
}
export const CheckoutProvider: React.FC<CheckoutProviderProps> = ({
children,
defaultCurrency = 'gbp'
}) => {
const { t } = useTranslation();
const { refetchLicense } = useLicense();
const [isOpen, setIsOpen] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const [selectedPlanGroup, setSelectedPlanGroup] = useState<PlanTierGroup | null>(null);
const [minimumSeats, setMinimumSeats] = useState<number>(1);
const [currentCurrency, setCurrentCurrency] = useState(defaultCurrency);
const [currentOptions, setCurrentOptions] = useState<CheckoutOptions>({});
const [hostedCheckoutSuccess, setHostedCheckoutSuccess] = useState<{
isUpgrade: boolean;
licenseKey?: string;
} | null>(null);
// Load plans with current currency
const { plans, refetch: refetchPlans } = usePlans(currentCurrency);
// Handle return from hosted Stripe checkout
useEffect(() => {
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);
// Fetch current license info to determine upgrade vs new
let licenseInfo: LicenseInfo | null = null;
try {
licenseInfo = await licenseService.getLicenseInfo();
} catch (err) {
console.warn('Could not fetch license info:', err);
}
// Check if this is an upgrade or new subscription
if (licenseInfo?.licenseKey) {
// UPGRADE: Resync existing license with Keygen
console.log('Upgrade detected - resyncing existing license');
const activation = await resyncExistingLicense();
if (activation.success) {
console.log('License synced successfully, refreshing license context');
// Refresh global license context
await refetchLicense();
await refetchPlans();
// Determine tier from license type
const tier = activation.licenseType === 'ENTERPRISE' ? 'enterprise' : 'server';
const planGroups = licenseService.groupPlansByTier(plans);
const planGroup = planGroups.find(pg => pg.tier === tier);
if (planGroup) {
// Reopen modal to show success
setSelectedPlanGroup(planGroup);
setHostedCheckoutSuccess({ isUpgrade: true });
setIsOpen(true);
} else {
// Fallback to toast if plan group not found
alert({
alertType: 'success',
title: t('payment.upgradeSuccess'),
});
}
} else {
console.error('Failed to sync license after upgrade:', activation.error);
alert({
alertType: 'error',
title: t('payment.syncError'),
});
}
} else {
// NEW SUBSCRIPTION: Poll for license key
console.log('New subscription - polling for license key');
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);
if (activation.success) {
console.log(`License key activated: ${activation.licenseType}`);
// Refresh global license context
await refetchLicense();
await refetchPlans();
// Determine tier from license type
const tier = activation.licenseType === 'ENTERPRISE' ? 'enterprise' : 'server';
const planGroups = licenseService.groupPlansByTier(plans);
const planGroup = planGroups.find(pg => pg.tier === tier);
if (planGroup) {
// Reopen modal to show success with license key
setSelectedPlanGroup(planGroup);
setHostedCheckoutSuccess({
isUpgrade: false,
licenseKey: result.licenseKey
});
setIsOpen(true);
} else {
// Fallback to toast if plan group not found
alert({
alertType: 'success',
title: t('payment.licenseActivated'),
});
}
} else {
console.error('Failed to save license key:', activation.error);
alert({
alertType: 'error',
title: t('payment.licenseSaveError'),
});
}
} else if (result.timedOut) {
console.warn('License key polling timed out');
alert({
alertType: 'warning',
title: t('payment.licenseDelayed'),
});
} else {
console.error('License key polling failed:', result.error);
alert({
alertType: 'error',
title: t('payment.licensePollingError'),
});
}
} catch (error) {
console.error('Failed to poll for license key:', error);
alert({
alertType: 'error',
title: t('payment.licenseRetrievalError'),
});
}
}
} else if (paymentStatus === 'canceled') {
console.log('Payment canceled by user');
// Clear URL parameters
window.history.replaceState({}, '', window.location.pathname);
alert({
alertType: 'warning',
title: t('payment.paymentCanceled'),
});
}
};
handleCheckoutReturn();
}, [t, refetchPlans, refetchLicense, plans]);
const openCheckout = useCallback(
async (tier: 'server' | 'enterprise', options: CheckoutOptions = {}) => {
try {
setIsLoading(true);
// Check if Supabase is configured
if (!isSupabaseConfigured) {
throw new Error('Checkout is not available. Supabase is not configured.');
}
// Update currency if provided
const currency = options.currency || currentCurrency;
if (currency !== currentCurrency) {
setCurrentCurrency(currency);
// Plans will reload automatically via usePlans
}
// Fetch license info and user data for seat calculations
let licenseInfo: LicenseInfo | null = null;
let totalUsers = 0;
try {
const [licenseData, userData] = await Promise.all([
licenseService.getLicenseInfo(),
userManagementService.getUsers()
]);
licenseInfo = licenseData;
totalUsers = userData.totalUsers || 0;
} catch (err) {
console.warn('Could not fetch license/user info, proceeding with defaults:', err);
}
// Calculate minimum seats for enterprise upgrades
let calculatedMinSeats = options.minimumSeats || 1;
if (tier === 'enterprise' && !options.minimumSeats) {
const currentTier = mapLicenseToTier(licenseInfo);
if (currentTier === 'server' || currentTier === 'free') {
// Upgrading from Server (unlimited) to Enterprise (per-seat)
// Use current total user count as minimum
calculatedMinSeats = Math.max(totalUsers, 1);
console.log(`Setting minimum seats from server user count: ${calculatedMinSeats}`);
} else if (currentTier === 'enterprise') {
// Upgrading within Enterprise (e.g., monthly to yearly)
// Use current licensed seat count as minimum
calculatedMinSeats = Math.max(licenseInfo?.maxUsers || 1, 1);
console.log(`Setting minimum seats from current license: ${calculatedMinSeats}`);
}
}
// Find the plan group for the requested tier
const planGroups = licenseService.groupPlansByTier(plans);
const planGroup = planGroups.find(pg => pg.tier === tier);
if (!planGroup) {
throw new Error(`No ${tier} plan available`);
}
// Store options for callbacks
setCurrentOptions(options);
setMinimumSeats(calculatedMinSeats);
setSelectedPlanGroup(planGroup);
setIsOpen(true);
} catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Failed to open checkout';
console.error('Error opening checkout:', errorMessage);
options.onError?.(errorMessage);
} finally {
setIsLoading(false);
}
},
[currentCurrency, plans]
);
const closeCheckout = useCallback(() => {
setIsOpen(false);
setSelectedPlanGroup(null);
setCurrentOptions({});
setHostedCheckoutSuccess(null);
// Refetch plans and license after modal closes to update subscription display
refetchPlans();
refetchLicense();
}, [refetchPlans, refetchLicense]);
const handlePaymentSuccess = useCallback(
(sessionId: string) => {
console.log('Payment successful, session:', sessionId);
currentOptions.onSuccess?.(sessionId);
// Don't close modal - let user view license key and close manually
},
[currentOptions]
);
const handlePaymentError = useCallback(
(error: string) => {
console.error('Payment error:', error);
currentOptions.onError?.(error);
},
[currentOptions]
);
const handleLicenseActivated = useCallback((licenseInfo: {
licenseType: string;
enabled: boolean;
maxUsers: number;
hasKey: boolean;
}) => {
console.log('License activated:', licenseInfo);
// Could expose this via context if needed
}, []);
const contextValue: CheckoutContextValue = {
openCheckout,
closeCheckout,
isOpen,
isLoading,
};
return (
<CheckoutContext.Provider value={contextValue}>
{children}
{/* Global Checkout Modal */}
{selectedPlanGroup && (
<StripeCheckout
opened={isOpen}
onClose={closeCheckout}
planGroup={selectedPlanGroup}
minimumSeats={minimumSeats}
onSuccess={handlePaymentSuccess}
onError={handlePaymentError}
onLicenseActivated={handleLicenseActivated}
hostedCheckoutSuccess={hostedCheckoutSuccess}
/>
)}
</CheckoutContext.Provider>
);
};
export const useCheckout = (): CheckoutContextValue => {
const context = useContext(CheckoutContext);
if (!context) {
throw new Error('useCheckout must be used within CheckoutProvider');
}
return context;
};
@@ -0,0 +1,74 @@
import React, { createContext, useContext, useState, useCallback, useEffect, ReactNode } from 'react';
import licenseService, { LicenseInfo } from '@app/services/licenseService';
import { useAppConfig } from '@app/contexts/AppConfigContext';
interface LicenseContextValue {
licenseInfo: LicenseInfo | null;
loading: boolean;
error: string | null;
refetchLicense: () => Promise<void>;
}
const LicenseContext = createContext<LicenseContextValue | undefined>(undefined);
interface LicenseProviderProps {
children: ReactNode;
}
export const LicenseProvider: React.FC<LicenseProviderProps> = ({ children }) => {
const { config } = useAppConfig();
const [licenseInfo, setLicenseInfo] = useState<LicenseInfo | null>(null);
const [loading, setLoading] = useState<boolean>(true);
const [error, setError] = useState<string | null>(null);
const refetchLicense = useCallback(async () => {
// Only fetch license info if user is an admin
if (!config?.isAdmin) {
console.debug('[LicenseContext] User is not an admin, skipping license fetch');
setLoading(false);
return;
}
try {
setLoading(true);
setError(null);
const info = await licenseService.getLicenseInfo();
setLicenseInfo(info);
} catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Failed to fetch license info';
console.error('Error fetching license info:', errorMessage);
setError(errorMessage);
setLicenseInfo(null);
} finally {
setLoading(false);
}
}, [config?.isAdmin]);
// Fetch license info when config changes (only if user is admin)
useEffect(() => {
if (config) {
refetchLicense();
}
}, [config, refetchLicense]);
const contextValue: LicenseContextValue = {
licenseInfo,
loading,
error,
refetchLicense,
};
return (
<LicenseContext.Provider value={contextValue}>
{children}
</LicenseContext.Provider>
);
};
export const useLicense = (): LicenseContextValue => {
const context = useContext(LicenseContext);
if (!context) {
throw new Error('useLicense must be used within LicenseProvider');
}
return context;
};