diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/configuration/ee/LicenseKeyChecker.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/configuration/ee/LicenseKeyChecker.java index 212922d55..68e4ab707 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/configuration/ee/LicenseKeyChecker.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/configuration/ee/LicenseKeyChecker.java @@ -113,7 +113,7 @@ public class LicenseKeyChecker { public void updateLicenseKey(String newKey) throws IOException { applicationProperties.getPremium().setKey(newKey); - GeneralUtils.saveKeyToSettings("EnterpriseEdition.key", newKey); + GeneralUtils.saveKeyToSettings("premium.key", newKey); evaluateLicense(); synchronizeLicenseSettings(); } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/AdminLicenseController.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/AdminLicenseController.java similarity index 80% rename from app/proprietary/src/main/java/stirling/software/proprietary/controller/api/AdminLicenseController.java rename to app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/AdminLicenseController.java index fdad57b95..0f83ba602 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/AdminLicenseController.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/AdminLicenseController.java @@ -1,4 +1,4 @@ -package stirling.software.proprietary.controller.api; +package stirling.software.proprietary.security.controller.api; import java.util.HashMap; import java.util.Map; @@ -19,6 +19,7 @@ import lombok.extern.slf4j.Slf4j; import stirling.software.common.model.ApplicationProperties; import stirling.software.common.util.GeneralUtils; +import stirling.software.proprietary.security.configuration.ee.KeygenLicenseVerifier; import stirling.software.proprietary.security.configuration.ee.KeygenLicenseVerifier.License; import stirling.software.proprietary.security.configuration.ee.LicenseKeyChecker; @@ -36,6 +37,9 @@ public class AdminLicenseController { @Autowired(required = false) private LicenseKeyChecker licenseKeyChecker; + @Autowired(required = false) + private KeygenLicenseVerifier keygenLicenseVerifier; + @Autowired private ApplicationProperties applicationProperties; /** @@ -89,6 +93,8 @@ public class AdminLicenseController { return ResponseEntity.internalServerError() .body(Map.of("success", false, "error", "License checker not available")); } + // assume premium enabled when setting license key + applicationProperties.getPremium().setEnabled(true); // Use existing LicenseKeyChecker to update and validate license licenseKeyChecker.updateLicenseKey(licenseKey.trim()); @@ -96,9 +102,29 @@ public class AdminLicenseController { // Get current license status License license = licenseKeyChecker.getPremiumLicenseEnabledResult(); + // Auto-enable premium features if license is valid + if (license != License.NORMAL) { + GeneralUtils.saveKeyToSettings("premium.enabled", true); + // Enable premium features + + // Save maxUsers from license metadata + Integer maxUsers = applicationProperties.getPremium().getMaxUsers(); + if (maxUsers != null) { + GeneralUtils.saveKeyToSettings("premium.maxUsers", maxUsers); + } + + log.info( + "Premium features enabled: type={}, maxUsers={}", license.name(), maxUsers); + } else { + GeneralUtils.saveKeyToSettings("premium.enabled", false); + log.info("License key is not valid for premium features: type={}", license.name()); + } + Map response = new HashMap<>(); response.put("success", true); response.put("licenseType", license.name()); + response.put("enabled", applicationProperties.getPremium().isEnabled()); + response.put("maxUsers", applicationProperties.getPremium().getMaxUsers()); response.put("requiresRestart", false); // Dynamic evaluation works response.put("message", "License key saved and activated"); @@ -145,6 +171,11 @@ public class AdminLicenseController { response.put("maxUsers", premium.getMaxUsers()); response.put("hasKey", premium.getKey() != null && !premium.getKey().trim().isEmpty()); + // Include license key for upgrades (admin-only endpoint) + if (premium.getKey() != null && !premium.getKey().trim().isEmpty()) { + response.put("licenseKey", premium.getKey()); + } + return ResponseEntity.ok(response); } catch (Exception e) { log.error("Failed to get license info", e); diff --git a/frontend/src/proprietary/components/AppProviders.tsx b/frontend/src/proprietary/components/AppProviders.tsx index f0cadfa51..b4839fbba 100644 --- a/frontend/src/proprietary/components/AppProviders.tsx +++ b/frontend/src/proprietary/components/AppProviders.tsx @@ -1,5 +1,6 @@ import { AppProviders as CoreAppProviders, AppProvidersProps } from "@core/components/AppProviders"; import { AuthProvider } from "@app/auth/UseSession"; +import { CheckoutProvider } from "@app/contexts/CheckoutContext"; export function AppProviders({ children, appConfigRetryOptions, appConfigProviderProps }: AppProvidersProps) { return ( @@ -8,7 +9,9 @@ export function AppProviders({ children, appConfigRetryOptions, appConfigProvide appConfigProviderProps={appConfigProviderProps} > - {children} + + {children} + ); diff --git a/frontend/src/core/components/shared/ManageBillingButton.tsx b/frontend/src/proprietary/components/shared/ManageBillingButton.tsx similarity index 100% rename from frontend/src/core/components/shared/ManageBillingButton.tsx rename to frontend/src/proprietary/components/shared/ManageBillingButton.tsx diff --git a/frontend/src/proprietary/components/shared/StripeCheckout.tsx b/frontend/src/proprietary/components/shared/StripeCheckout.tsx index 88c3bbe5c..7465141e2 100644 --- a/frontend/src/proprietary/components/shared/StripeCheckout.tsx +++ b/frontend/src/proprietary/components/shared/StripeCheckout.tsx @@ -13,7 +13,7 @@ interface StripeCheckoutProps { opened: boolean; onClose: () => void; planGroup: PlanTierGroup; - email: string; + minimumSeats?: number; onSuccess?: (sessionId: string) => void; onError?: (error: string) => void; onLicenseActivated?: (licenseInfo: {licenseType: string; enabled: boolean; maxUsers: number; hasKey: boolean}) => void; @@ -30,7 +30,7 @@ const StripeCheckout: React.FC = ({ opened, onClose, planGroup, - email, + minimumSeats = 1, onSuccess, onError, onLicenseActivated, @@ -67,11 +67,24 @@ const StripeCheckout: React.FC = ({ setInstallationId(fetchedInstallationId); } + // Fetch current license key for upgrades + let currentLicenseKey: string | undefined; + try { + const licenseInfo = await licenseService.getLicenseInfo(); + if (licenseInfo && licenseInfo.licenseKey) { + currentLicenseKey = licenseInfo.licenseKey; + console.log('Found existing license for upgrade'); + } + } catch (error) { + console.warn('Could not fetch license info, proceeding as new license:', error); + } + const response = await licenseService.createCheckoutSession({ lookup_key: selectedPlan.lookupKey, - email, installation_id: fetchedInstallationId, + current_license_key: currentLicenseKey, requires_seats: selectedPlan.requiresSeats, + seat_count: minimumSeats, successUrl: `${window.location.origin}/settings/adminPlan?session_id={CHECKOUT_SESSION_ID}`, cancelUrl: `${window.location.origin}/settings/adminPlan`, }); @@ -210,6 +223,7 @@ const StripeCheckout: React.FC = ({ ); case 'ready': + { if (!state.clientSecret || !selectedPlan) return null; // Build period selector data with prices @@ -275,7 +289,7 @@ const StripeCheckout: React.FC = ({ ); - + } case 'success': return ( diff --git a/frontend/src/proprietary/components/shared/config/configSections/AdminPlanSection.tsx b/frontend/src/proprietary/components/shared/config/configSections/AdminPlanSection.tsx index c5d3807dc..51acef616 100644 --- a/frontend/src/proprietary/components/shared/config/configSections/AdminPlanSection.tsx +++ b/frontend/src/proprietary/components/shared/config/configSections/AdminPlanSection.tsx @@ -2,12 +2,11 @@ import React, { useState, useCallback, useEffect } from 'react'; import { Divider, Loader, Alert, Select, Group, Text, Collapse, Button, TextInput, Stack, Paper } from '@mantine/core'; import { useTranslation } from 'react-i18next'; import { usePlans } from '@app/hooks/usePlans'; -import licenseService, { PlanTierGroup } from '@app/services/licenseService'; -import StripeCheckout from '@app/components/shared/StripeCheckout'; +import licenseService, { PlanTierGroup, LicenseInfo } from '@app/services/licenseService'; +import { useCheckout } from '@app/contexts/CheckoutContext'; import AvailablePlansSection from '@app/components/shared/config/configSections/plan/AvailablePlansSection'; import ActivePlanSection from '@app/components/shared/config/configSections/plan//ActivePlanSection'; import StaticPlanSection from '@app/components/shared/config/configSections/plan//StaticPlanSection'; -import { userManagementService } from '@app/services/userManagementService'; import { useAppConfig } from '@app/contexts/AppConfigContext'; import { alert } from '@app/components/toast'; import LocalIcon from '@app/components/shared/LocalIcon'; @@ -15,6 +14,7 @@ import RestartConfirmationModal from '@app/components/shared/config/RestartConfi import { useRestartServer } from '@app/components/shared/config/useRestartServer'; import { useAdminSettings } from '@app/hooks/useAdminSettings'; import PendingBadge from '@app/components/shared/config/PendingBadge'; +import { Z_INDEX_OVER_CONFIG_MODAL } from '@app/styles/zIndex'; interface PremiumSettingsData { key?: string; @@ -24,15 +24,11 @@ interface PremiumSettingsData { const AdminPlanSection: React.FC = () => { const { t } = useTranslation(); const { config } = useAppConfig(); - const [checkoutOpen, setCheckoutOpen] = useState(false); - const [selectedPlanGroup, setSelectedPlanGroup] = useState(null); + const { openCheckout } = useCheckout(); const [currency, setCurrency] = useState('gbp'); const [useStaticVersion, setUseStaticVersion] = useState(false); - const [currentLicenseInfo, setCurrentLicenseInfo] = useState(null); - const [licenseInfoLoading, setLicenseInfoLoading] = useState(false); - const [licenseInfoError, setLicenseInfoError] = useState(null); + const [currentLicenseInfo, setCurrentLicenseInfo] = useState(null); const [showLicenseKey, setShowLicenseKey] = useState(false); - const [email, setEmail] = useState(''); const { plans, currentSubscription, loading, error, refetch } = usePlans(currency); // Premium/License key management @@ -53,40 +49,16 @@ const AdminPlanSection: React.FC = () => { useEffect(() => { const fetchLicenseInfo = async () => { try { - console.log('Fetching user and license info for plan section'); - const adminData = await userManagementService.getUsers(); - - // Determine plan name based on config flags - let planName = 'Free'; - if (config?.runningEE) { - planName = 'Enterprise'; - } else if (config?.runningProOrHigher || adminData.premiumEnabled) { - planName = 'Pro'; - } - - setCurrentLicenseInfo({ - planName, - maxUsers: adminData.maxAllowedUsers, - grandfathered: adminData.grandfatheredUserCount > 0, - }); - - // Also fetch license info from new backend endpoint + // Fetch license info from backend endpoint try { - setLicenseInfoLoading(true); - setLicenseInfoError(null); const backendLicenseInfo = await licenseService.getLicenseInfo(); setCurrentLicenseInfo(backendLicenseInfo); - setLicenseInfoLoading(false); } catch (licenseErr: any) { console.error('Failed to fetch backend license info:', licenseErr); - setLicenseInfoLoading(false); - setLicenseInfoError(licenseErr?.response?.data?.error || licenseErr?.message || 'Unknown error'); - // Don't overwrite existing info if backend call fails } } catch (err) { console.error('Failed to fetch license info:', err); } - }; // Check if Stripe is configured @@ -125,62 +97,27 @@ const AdminPlanSection: React.FC = () => { const handleUpgradeClick = useCallback( (planGroup: PlanTierGroup) => { - // Validate email is provided - if (!email || !email.trim()) { - alert({ - alertType: 'warning', - title: t('admin.plan.emailRequired.title', 'Email Required'), - body: t('admin.plan.emailRequired.message', 'Please enter your email address before proceeding'), - }); - return; - } - - // Validate email format - const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; - if (!emailRegex.test(email)) { - alert({ - alertType: 'warning', - title: t('admin.plan.invalidEmail.title', 'Invalid Email'), - body: t('admin.plan.invalidEmail.message', 'Please enter a valid email address'), - }); - return; - } - - setSelectedPlanGroup(planGroup); - setCheckoutOpen(true); + // Use checkout context to open checkout modal + openCheckout(planGroup.tier, { + currency, + onSuccess: () => { + // Refetch plans and license info after successful payment + refetch(); + const fetchLicenseInfo = async () => { + try { + const backendLicenseInfo = await licenseService.getLicenseInfo(); + setCurrentLicenseInfo(backendLicenseInfo); + } catch (err) { + console.error('Failed to refetch license info:', err); + } + }; + fetchLicenseInfo(); + }, + }); }, - [email, t] + [openCheckout, currency, refetch] ); - const handlePaymentSuccess = useCallback( - (sessionId: string) => { - console.log('Payment successful, session:', sessionId); - - // Don't refetch here - will refetch when modal closes to avoid re-renders - // Don't close modal - let user view license key and close manually - // Modal will show "You can now close this window" when ready - }, - [] - ); - - const handlePaymentError = useCallback((error: string) => { - console.error('Payment error:', error); - // Error is already displayed in the StripeCheckout component - }, []); - - const handleLicenseActivated = useCallback((licenseInfo: {licenseType: string; enabled: boolean; maxUsers: number; hasKey: boolean}) => { - console.log('License activated:', licenseInfo); - setCurrentLicenseInfo(licenseInfo); - }, []); - - const handleCheckoutClose = useCallback(() => { - setCheckoutOpen(false); - setSelectedPlanGroup(null); - - // Refetch plans after modal closes to update subscription display - refetch(); - }, [refetch]); - // Show static version if Stripe is not configured or there's an error if (useStaticVersion) { return ; @@ -210,66 +147,22 @@ const AdminPlanSection: React.FC = () => { return (
- {/* License Information Display - Always visible */} - - {licenseInfoLoading ? ( - - - {t('admin.plan.loadingLicense', 'Loading license information...')} - - ) : licenseInfoError ? ( - {t('admin.plan.licenseError', 'Failed to load license info')}: {licenseInfoError} - ) : currentLicenseInfo ? ( - - - {t('admin.plan.licenseType', 'License Type')}: {currentLicenseInfo.licenseType} - - - {t('admin.plan.status', 'Status')}: {currentLicenseInfo.enabled ? t('admin.plan.active', 'Active') : t('admin.plan.inactive', 'Inactive')} - - {currentLicenseInfo.licenseType === 'ENTERPRISE' && currentLicenseInfo.maxUsers > 0 && ( - - {t('admin.plan.maxUsers', 'Max Users')}: {currentLicenseInfo.maxUsers} - - )} - - ) : ( - {t('admin.plan.noLicenseInfo', 'No license information available')} - )} - - - {/* Customer Information Section */} + {/* Currency Selection */} - + - {t('admin.plan.customerInfo', 'Customer Information')} + {t('plan.currency', 'Currency')} - setEmail(e.target.value)} - required - type="email" + setCurrency(value || 'gbp')} - data={currencyOptions} - searchable - clearable={false} - w={300} - /> - - + {currentSubscription && ( @@ -282,22 +175,10 @@ const AdminPlanSection: React.FC = () => { - {/* Stripe Checkout Modal */} - {selectedPlanGroup && ( - - )} - {/* License Key Section */} diff --git a/frontend/src/proprietary/components/shared/config/configSections/plan/AvailablePlansSection.tsx b/frontend/src/proprietary/components/shared/config/configSections/plan/AvailablePlansSection.tsx index 1b5cdea90..22e87f2ad 100644 --- a/frontend/src/proprietary/components/shared/config/configSections/plan/AvailablePlansSection.tsx +++ b/frontend/src/proprietary/components/shared/config/configSections/plan/AvailablePlansSection.tsx @@ -1,18 +1,20 @@ import React, { useState, useMemo } from 'react'; import { Button, Card, Badge, Text, Collapse } from '@mantine/core'; import { useTranslation } from 'react-i18next'; -import licenseService, { PlanTier, PlanTierGroup } from '@app/services/licenseService'; +import licenseService, { PlanTier, PlanTierGroup, LicenseInfo, mapLicenseToTier } from '@app/services/licenseService'; import PlanCard from './PlanCard'; interface AvailablePlansSectionProps { plans: PlanTier[]; currentPlanId?: string; - onUpgradeClick: (plan: PlanTier) => void; + currentLicenseInfo?: LicenseInfo | null; + onUpgradeClick: (planGroup: PlanTierGroup) => void; } const AvailablePlansSection: React.FC = ({ plans, currentPlanId, + currentLicenseInfo, onUpgradeClick, }) => { const { t } = useTranslation(); @@ -23,13 +25,42 @@ const AvailablePlansSection: React.FC = ({ return licenseService.groupPlansByTier(plans); }, [plans]); - // Determine if the current tier matches + // Calculate current tier from license info + const currentTier = useMemo(() => { + return mapLicenseToTier(currentLicenseInfo || null); + }, [currentLicenseInfo]); + + // Determine if the current tier matches (checks both Stripe subscription and license) const isCurrentTier = (tierGroup: PlanTierGroup): boolean => { - if (!currentPlanId) return false; - return ( + // Check Stripe subscription match + if (currentPlanId && ( tierGroup.monthly?.id === currentPlanId || tierGroup.yearly?.id === currentPlanId - ); + )) { + return true; + } + // Check license tier match + if (currentTier && tierGroup.tier === currentTier) { + return true; + } + return false; + }; + + // Determine if selecting this plan would be a downgrade + const isDowngrade = (tierGroup: PlanTierGroup): boolean => { + if (!currentTier) return false; + + // Define tier hierarchy: enterprise > server > free + const tierHierarchy: Record = { + 'enterprise': 3, + 'server': 2, + 'free': 1 + }; + + const currentLevel = tierHierarchy[currentTier] || 0; + const targetLevel = tierHierarchy[tierGroup.tier] || 0; + + return currentLevel > targetLevel; }; return ( @@ -60,6 +91,8 @@ const AvailablePlansSection: React.FC = ({ key={group.tier} planGroup={group} isCurrentTier={isCurrentTier(group)} + isDowngrade={isDowngrade(group)} + currentLicenseInfo={currentLicenseInfo} onUpgradeClick={onUpgradeClick} /> ))} diff --git a/frontend/src/proprietary/components/shared/config/configSections/plan/PlanCard.tsx b/frontend/src/proprietary/components/shared/config/configSections/plan/PlanCard.tsx index 26acde500..20abc70a5 100644 --- a/frontend/src/proprietary/components/shared/config/configSections/plan/PlanCard.tsx +++ b/frontend/src/proprietary/components/shared/config/configSections/plan/PlanCard.tsx @@ -1,15 +1,17 @@ import React from 'react'; import { Button, Card, Badge, Text, Group, Stack, Divider } from '@mantine/core'; import { useTranslation } from 'react-i18next'; -import { PlanTierGroup } from '@app/services/licenseService'; +import { PlanTierGroup, LicenseInfo } from '@app/services/licenseService'; interface PlanCardProps { planGroup: PlanTierGroup; isCurrentTier: boolean; + isDowngrade: boolean; + currentLicenseInfo?: LicenseInfo | null; onUpgradeClick: (planGroup: PlanTierGroup) => void; } -const PlanCard: React.FC = ({ planGroup, isCurrentTier, onUpgradeClick }) => { +const PlanCard: React.FC = ({ planGroup, isCurrentTier, isDowngrade, currentLicenseInfo, onUpgradeClick }) => { const { t } = useTranslation(); // Render Free plan @@ -24,8 +26,20 @@ const PlanCard: React.FC = ({ planGroup, isCurrentTier, onUpgrade display: 'flex', flexDirection: 'column', minHeight: '400px', + borderColor: isCurrentTier ? 'var(--mantine-color-green-6)' : undefined, + borderWidth: isCurrentTier ? '2px' : undefined, }} > + {isCurrentTier && ( + + {t('plan.current', 'Current Plan')} + + )}
@@ -86,9 +100,20 @@ const PlanCard: React.FC = ({ planGroup, isCurrentTier, onUpgrade display: 'flex', flexDirection: 'column', minHeight: '400px', + borderColor: isCurrentTier ? 'var(--mantine-color-green-6)' : undefined, + borderWidth: isCurrentTier ? '2px' : undefined, }} > - {planGroup.popular && ( + {isCurrentTier ? ( + + {t('plan.current', 'Current Plan')} + + ) : planGroup.popular ? ( = ({ planGroup, isCurrentTier, onUpgrade > {t('plan.popular', 'Popular')} - )} + ) : null} {/* Tier Name */} @@ -137,7 +162,12 @@ const PlanCard: React.FC = ({ planGroup, isCurrentTier, onUpgrade )} - + {/* Show seat count for enterprise plans when current */} + {isEnterprise && isCurrentTier && currentLicenseInfo && currentLicenseInfo.maxUsers > 0 && ( + + {t('plan.licensedSeats', 'Licensed: {{count}} seats', { count: currentLicenseInfo.maxUsers })} + + )}
@@ -155,16 +185,18 @@ const PlanCard: React.FC = ({ planGroup, isCurrentTier, onUpgrade {/* Single Upgrade Button */}
diff --git a/frontend/src/proprietary/contexts/CheckoutContext.tsx b/frontend/src/proprietary/contexts/CheckoutContext.tsx new file mode 100644 index 000000000..21604eef8 --- /dev/null +++ b/frontend/src/proprietary/contexts/CheckoutContext.tsx @@ -0,0 +1,186 @@ +import React, { createContext, useContext, useState, useCallback, ReactNode } from 'react'; +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'; + +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; + closeCheckout: () => void; + isOpen: boolean; + isLoading: boolean; +} + +const CheckoutContext = createContext(undefined); + +interface CheckoutProviderProps { + children: ReactNode; + defaultCurrency?: string; +} + +export const CheckoutProvider: React.FC = ({ + children, + defaultCurrency = 'gbp' +}) => { + const [isOpen, setIsOpen] = useState(false); + const [isLoading, setIsLoading] = useState(false); + const [selectedPlanGroup, setSelectedPlanGroup] = useState(null); + const [minimumSeats, setMinimumSeats] = useState(1); + const [currentCurrency, setCurrentCurrency] = useState(defaultCurrency); + const [currentOptions, setCurrentOptions] = useState({}); + + // Load plans with current currency + const { plans, refetch: refetchPlans } = usePlans(currentCurrency); + + const openCheckout = useCallback( + async (tier: 'server' | 'enterprise', options: CheckoutOptions = {}) => { + try { + setIsLoading(true); + + // 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({}); + + // Refetch plans after modal closes to update subscription display + refetchPlans(); + }, [refetchPlans]); + + 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 ( + + {children} + + {/* Global Checkout Modal */} + {selectedPlanGroup && ( + + )} + + ); +}; + +export const useCheckout = (): CheckoutContextValue => { + const context = useContext(CheckoutContext); + if (!context) { + throw new Error('useCheckout must be used within CheckoutProvider'); + } + return context; +}; diff --git a/frontend/src/core/hooks/usePlans.ts b/frontend/src/proprietary/hooks/usePlans.ts similarity index 100% rename from frontend/src/core/hooks/usePlans.ts rename to frontend/src/proprietary/hooks/usePlans.ts diff --git a/frontend/src/core/services/licenseService.ts b/frontend/src/proprietary/services/licenseService.ts similarity index 92% rename from frontend/src/core/services/licenseService.ts rename to frontend/src/proprietary/services/licenseService.ts index e4fb9e945..4ee6a6d66 100644 --- a/frontend/src/core/services/licenseService.ts +++ b/frontend/src/proprietary/services/licenseService.ts @@ -47,8 +47,8 @@ export interface PlansResponse { export interface CheckoutSessionRequest { lookup_key: string; // Stripe lookup key (e.g., 'selfhosted:server:monthly') - email: string; // Customer email (required for self-hosted) installation_id?: string; // Installation ID from backend (MAC-based fingerprint) + current_license_key?: string; // Current license key for upgrades requires_seats?: boolean; // Whether to add adjustable seat pricing seat_count?: number; // Initial number of seats for enterprise plans (user can adjust in Stripe UI) successUrl?: string; @@ -75,6 +75,14 @@ export interface LicenseKeyResponse { plan?: string; } +export interface LicenseInfo { + licenseType: 'NORMAL' | 'PRO' | 'ENTERPRISE'; + enabled: boolean; + maxUsers: number; + hasKey: boolean; + licenseKey?: string; // The actual license key (for upgrades) +} + // Currency symbol mapping const getCurrencySymbol = (currency: string): string => { const currencySymbols: { [key: string]: string } = { @@ -365,8 +373,8 @@ const licenseService = { body: { self_hosted: true, lookup_key: request.lookup_key, - email: request.email, installation_id: request.installation_id, + current_license_key: request.current_license_key, requires_seats: request.requires_seats, seat_count: request.seat_count || 1, callback_base_url: window.location.origin, @@ -449,7 +457,7 @@ const licenseService = { /** * Get current license information from backend */ - async getLicenseInfo(): Promise<{licenseType: string; enabled: boolean; maxUsers: number; hasKey: boolean}> { + async getLicenseInfo(): Promise { try { const response = await apiClient.get('/api/v1/admin/license-info'); return response.data; @@ -460,4 +468,31 @@ const licenseService = { }, }; +/** + * Map license type to plan tier + * @param licenseInfo - Current license information + * @returns Plan tier: 'free' | 'server' | 'enterprise' + */ +export const mapLicenseToTier = (licenseInfo: LicenseInfo | null): 'free' | 'server' | 'enterprise' | null => { + if (!licenseInfo) return null; + + // No license or NORMAL type = Free tier + if (licenseInfo.licenseType === 'NORMAL' || !licenseInfo.enabled) { + return 'free'; + } + + // PRO type (no seats) = Server tier + if (licenseInfo.licenseType === 'PRO') { + return 'server'; + } + + // ENTERPRISE type (with seats) = Enterprise tier + if (licenseInfo.licenseType === 'ENTERPRISE' && licenseInfo.maxUsers > 0) { + return 'enterprise'; + } + + // Default fallback + return 'free'; +}; + export default licenseService;