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,20 @@
import { createClient, SupabaseClient } from '@supabase/supabase-js';
const supabaseUrl = import.meta.env.VITE_SUPABASE_URL;
const supabaseAnonKey = import.meta.env.VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY;
// Check if Supabase is configured
export const isSupabaseConfigured = !!(supabaseUrl && supabaseAnonKey);
// Create client only if configured, otherwise export null
export const supabase: SupabaseClient | null = isSupabaseConfigured
? createClient(supabaseUrl, supabaseAnonKey)
: null;
// Log warning if not configured (for self-hosted installations)
if (!isSupabaseConfigured) {
console.warn(
'Supabase is not configured. Checkout and billing features will be disabled. ' +
'Static plan information will be displayed instead.'
);
}
@@ -1,5 +1,8 @@
import { AppProviders as CoreAppProviders, AppProvidersProps } from "@core/components/AppProviders";
import { AuthProvider } from "@app/auth/UseSession";
import { LicenseProvider } from "@app/contexts/LicenseContext";
import { CheckoutProvider } from "@app/contexts/CheckoutContext";
import UpgradeBanner from "@app/components/shared/UpgradeBanner";
export function AppProviders({ children, appConfigRetryOptions, appConfigProviderProps }: AppProvidersProps) {
return (
@@ -8,7 +11,12 @@ export function AppProviders({ children, appConfigRetryOptions, appConfigProvide
appConfigProviderProps={appConfigProviderProps}
>
<AuthProvider>
{children}
<LicenseProvider>
<CheckoutProvider>
<UpgradeBanner />
{children}
</CheckoutProvider>
</LicenseProvider>
</AuthProvider>
</CoreAppProviders>
);
@@ -0,0 +1,53 @@
import React, { useState } from 'react';
import { Button } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import licenseService from '@app/services/licenseService';
import { alert } from '@app/components/toast';
interface ManageBillingButtonProps {
returnUrl?: string;
}
export const ManageBillingButton: React.FC<ManageBillingButtonProps> = ({
returnUrl = window.location.href,
}) => {
const { t } = useTranslation();
const [loading, setLoading] = useState(false);
const handleClick = async () => {
try {
setLoading(true);
// Get current license key for authentication
const licenseInfo = await licenseService.getLicenseInfo();
if (!licenseInfo.licenseKey) {
throw new Error('No license key found. Please activate a license first.');
}
// Create billing portal session with license key
const response = await licenseService.createBillingPortalSession(
returnUrl,
licenseInfo.licenseKey
);
// Open billing portal in new tab
window.open(response.url, '_blank');
setLoading(false);
} catch (error: any) {
console.error('Failed to open billing portal:', error);
alert({
alertType: 'error',
title: t('billing.portal.error', 'Failed to open billing portal'),
body: error.message || 'Please try again or contact support.',
});
setLoading(false);
}
};
return (
<Button variant="outline" onClick={handleClick} loading={loading}>
{t('billing.manageBilling', 'Manage Billing')}
</Button>
);
};
@@ -0,0 +1,507 @@
import React, { useState, useEffect, useCallback } from 'react';
import { Modal, Button, Text, Alert, Loader, Stack, Group, Paper, SegmentedControl, Grid, Code } from '@mantine/core';
import { useTranslation } from 'react-i18next';
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, resyncExistingLicense } from '@app/utils/licenseCheckoutUtils';
// Validate Stripe key (static validation, no dynamic imports)
const STRIPE_KEY = import.meta.env.VITE_STRIPE_PUBLISHABLE_KEY;
if (!STRIPE_KEY) {
console.error(
'VITE_STRIPE_PUBLISHABLE_KEY environment variable is required. ' +
'Please add it to your .env file. ' +
'Get your key from https://dashboard.stripe.com/apikeys'
);
}
if (STRIPE_KEY && !STRIPE_KEY.startsWith('pk_')) {
console.error(
`Invalid Stripe publishable key format. ` +
`Expected key starting with 'pk_', got: ${STRIPE_KEY.substring(0, 10)}...`
);
}
const stripePromise = STRIPE_KEY ? loadStripe(STRIPE_KEY) : null;
interface StripeCheckoutProps {
opened: boolean;
onClose: () => void;
planGroup: PlanTierGroup;
minimumSeats?: number;
onSuccess?: (sessionId: string) => void;
onError?: (error: string) => void;
onLicenseActivated?: (licenseInfo: {licenseType: string; enabled: boolean; maxUsers: number; hasKey: boolean}) => void;
hostedCheckoutSuccess?: {
isUpgrade: boolean;
licenseKey?: string;
} | null;
}
type CheckoutState = {
status: 'idle' | 'loading' | 'ready' | 'success' | 'error';
clientSecret?: string;
error?: string;
sessionId?: string;
};
const StripeCheckout: React.FC<StripeCheckoutProps> = ({
opened,
onClose,
planGroup,
minimumSeats = 1,
onSuccess,
onError,
onLicenseActivated,
hostedCheckoutSuccess,
}) => {
const { t } = useTranslation();
const [state, setState] = useState<CheckoutState>({ status: 'idle' });
// Default to yearly if available (better value), otherwise monthly
const [selectedPeriod, setSelectedPeriod] = useState<'monthly' | 'yearly'>(
planGroup.yearly ? 'yearly' : 'monthly'
);
const [installationId, setInstallationId] = useState<string | null>(null);
const [currentLicenseKey, setCurrentLicenseKey] = useState<string | null>(null);
const [licenseKey, setLicenseKey] = useState<string | null>(null);
const [pollingStatus, setPollingStatus] = useState<'idle' | 'polling' | 'ready' | 'timeout'>('idle');
// Refs for polling cleanup
const isMountedRef = React.useRef(true);
const pollingTimeoutRef = React.useRef<NodeJS.Timeout | null>(null);
// Get the selected plan based on period
const selectedPlan = selectedPeriod === 'yearly' ? planGroup.yearly : planGroup.monthly;
const createCheckoutSession = async () => {
if (!selectedPlan) {
setState({
status: 'error',
error: 'Selected plan period is not available',
});
return;
}
try {
setState({ status: 'loading' });
// Fetch installation ID from backend
let fetchedInstallationId = installationId;
if (!fetchedInstallationId) {
fetchedInstallationId = await licenseService.getInstallationId();
setInstallationId(fetchedInstallationId);
}
// Fetch current license key for upgrades
let existingLicenseKey: string | undefined;
try {
const licenseInfo = await licenseService.getLicenseInfo();
if (licenseInfo && licenseInfo.licenseKey) {
existingLicenseKey = licenseInfo.licenseKey;
setCurrentLicenseKey(existingLicenseKey);
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,
installation_id: fetchedInstallationId,
current_license_key: existingLicenseKey,
requires_seats: selectedPlan.requiresSeats,
seat_count: Math.max(1, Math.min(minimumSeats || 1, 10000)),
});
// 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,
sessionId: response.sessionId,
});
} catch (err) {
const errorMessage =
err instanceof Error ? err.message : 'Failed to create checkout session';
setState({
status: 'error',
error: errorMessage,
});
onError?.(errorMessage);
}
};
const pollForLicenseKey = useCallback(async (installId: string) => {
// Use shared polling utility
const result = await pollLicenseKeyWithBackoff(installId, {
isMounted: () => isMountedRef.current,
onStatusChange: setPollingStatus,
});
if (result.success && result.licenseKey) {
setLicenseKey(result.licenseKey);
// 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);
}
} 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 () => {
// Preserve state when changing status
setState(prev => ({ ...prev, status: 'success' }));
// Check if this is an upgrade (existing license key) or new plan
if (currentLicenseKey) {
// UPGRADE FLOW: Resync existing license with Keygen
console.log('Upgrade detected - resyncing existing license with Keygen');
setPollingStatus('polling');
const activation = await resyncExistingLicense({
isMounted: () => true, // Modal is open, no need to check
onActivated: onLicenseActivated,
});
if (activation.success) {
console.log(`License upgraded successfully: ${activation.licenseType}`);
setPollingStatus('ready');
} else {
console.error('Failed to sync upgraded license:', activation.error);
setPollingStatus('timeout');
}
// Notify parent (don't wait - upgrade is complete)
onSuccess?.(state.sessionId || '');
} else {
// NEW PLAN FLOW: Poll for new license key
console.log('New subscription - polling for license key');
if (installationId) {
pollForLicenseKey(installationId).finally(() => {
// Only notify parent after polling completes or times out
onSuccess?.(state.sessionId || '');
});
} else {
// No installation ID, notify immediately
onSuccess?.(state.sessionId || '');
}
}
};
const handleClose = () => {
// Clear any active polling
if (pollingTimeoutRef.current) {
clearTimeout(pollingTimeoutRef.current);
pollingTimeoutRef.current = null;
}
setState({ status: 'idle' });
setPollingStatus('idle');
setCurrentLicenseKey(null);
setLicenseKey(null);
// Reset to default period on close
setSelectedPeriod(planGroup.yearly ? 'yearly' : 'monthly');
onClose();
};
const handlePeriodChange = (value: string) => {
setSelectedPeriod(value as 'monthly' | 'yearly');
// Reset state to trigger checkout reload
setState({ status: 'idle' });
};
// Cleanup on unmount
useEffect(() => {
isMountedRef.current = true;
return () => {
isMountedRef.current = false;
if (pollingTimeoutRef.current) {
clearTimeout(pollingTimeoutRef.current);
pollingTimeoutRef.current = null;
}
};
}, []);
// Handle hosted checkout success - open directly to success state
useEffect(() => {
if (opened && hostedCheckoutSuccess) {
console.log('Opening modal to success state for hosted checkout return');
// Set appropriate state based on upgrade vs new subscription
if (hostedCheckoutSuccess.isUpgrade) {
setCurrentLicenseKey('existing'); // Flag to indicate upgrade
setPollingStatus('ready');
} else if (hostedCheckoutSuccess.licenseKey) {
setLicenseKey(hostedCheckoutSuccess.licenseKey);
setPollingStatus('ready');
}
// Set to success state to show success UI
setState({ status: 'success' });
}
}, [opened, hostedCheckoutSuccess]);
// Initialize checkout when modal opens or period changes
useEffect(() => {
// Don't reset if we're showing success state (license key)
if (state.status === 'success') {
return;
}
// Skip initialization if opening for hosted checkout success
if (hostedCheckoutSuccess) {
return;
}
if (opened && state.status === 'idle') {
createCheckoutSession();
} else if (!opened) {
setState({ status: 'idle' });
}
}, [opened, selectedPeriod, state.status, hostedCheckoutSuccess]);
const renderContent = () => {
// Check if Stripe is configured
if (!stripePromise) {
return (
<Alert color="red" title={t('payment.stripeNotConfigured', 'Stripe Not Configured')}>
<Stack gap="md">
<Text size="sm">
{t(
'payment.stripeNotConfiguredMessage',
'Stripe payment integration is not configured. Please contact your administrator.'
)}
</Text>
<Button variant="outline" onClick={handleClose}>
{t('common.close', 'Close')}
</Button>
</Stack>
</Alert>
);
}
switch (state.status) {
case 'loading':
return (
<Stack align="center" justify="center" style={{ padding: '2rem 0' }}>
<Loader size="lg" />
<Text size="sm" c="dimmed" mt="md">
{t('payment.preparing', 'Preparing your checkout...')}
</Text>
</Stack>
);
case 'ready':
{
if (!state.clientSecret || !selectedPlan) return null;
// Build period selector data with prices
const periodData = [];
if (planGroup.monthly) {
const monthlyPrice = planGroup.monthly.requiresSeats && planGroup.monthly.seatPrice
? `${planGroup.monthly.currency}${planGroup.monthly.price.toFixed(2)}${planGroup.monthly.period} + ${planGroup.monthly.currency}${planGroup.monthly.seatPrice.toFixed(2)}/seat`
: `${planGroup.monthly.currency}${planGroup.monthly.price.toFixed(2)}${planGroup.monthly.period}`;
periodData.push({
value: 'monthly',
label: `${t('payment.monthly', 'Monthly')} - ${monthlyPrice}`,
});
}
if (planGroup.yearly) {
const yearlyPrice = planGroup.yearly.requiresSeats && planGroup.yearly.seatPrice
? `${planGroup.yearly.currency}${planGroup.yearly.price.toFixed(2)}${planGroup.yearly.period} + ${planGroup.yearly.currency}${planGroup.yearly.seatPrice.toFixed(2)}/seat`
: `${planGroup.yearly.currency}${planGroup.yearly.price.toFixed(2)}${planGroup.yearly.period}`;
periodData.push({
value: 'yearly',
label: `${t('payment.yearly', 'Yearly')} - ${yearlyPrice}`,
});
}
return (
<Grid gutter="md">
{/* Left: Period Selector - only show if both periods available */}
{periodData.length > 1 && (
<Grid.Col span={3}>
<Stack gap="sm" style={{ height: '100%' }}>
<Text size="sm" fw={600}>
{t('payment.billingPeriod', 'Billing Period')}
</Text>
<SegmentedControl
value={selectedPeriod}
onChange={handlePeriodChange}
data={periodData}
orientation="vertical"
fullWidth
/>
{selectedPlan.requiresSeats && selectedPlan.seatPrice && (
<Text size="xs" c="dimmed" mt="md">
{t('payment.enterpriseNote', 'Seats can be adjusted in checkout (1-1000).')}
</Text>
)}
</Stack>
</Grid.Col>
)}
{/* Right: Stripe Checkout */}
<Grid.Col span={periodData.length > 1 ? 9 : 12}>
<EmbeddedCheckoutProvider
key={state.clientSecret}
stripe={stripePromise}
options={{
clientSecret: state.clientSecret,
onComplete: handlePaymentComplete,
}}
>
<EmbeddedCheckout />
</EmbeddedCheckoutProvider>
</Grid.Col>
</Grid>
);
}
case 'success':
return (
<Alert color="green" title={t('payment.success', 'Payment Successful!')}>
<Stack gap="md">
<Text size="sm">
{t(
'payment.successMessage',
'Your subscription has been activated successfully.'
)}
</Text>
{/* License Key Polling Status */}
{pollingStatus === 'polling' && (
<Group gap="xs">
<Loader size="sm" />
<Text size="sm" c="dimmed">
{currentLicenseKey
? t('payment.syncingLicense', 'Syncing your upgraded license...')
: t('payment.generatingLicense', 'Generating your license key...')}
</Text>
</Group>
)}
{pollingStatus === 'ready' && !currentLicenseKey && licenseKey && (
<Paper withBorder p="md" radius="md" bg="gray.1">
<Stack gap="sm">
<Text size="sm" fw={600}>
{t('payment.licenseKey', 'Your License Key')}
</Text>
<Code block>{licenseKey}</Code>
<Button
variant="light"
size="sm"
onClick={() => navigator.clipboard.writeText(licenseKey)}
>
{t('common.copy', 'Copy to Clipboard')}
</Button>
<Text size="xs" c="dimmed">
{t(
'payment.licenseInstructions',
'Enter this key in Settings → Admin Plan → License Key section'
)}
</Text>
</Stack>
</Paper>
)}
{pollingStatus === 'ready' && currentLicenseKey && (
<Alert color="green" title={t('payment.upgradeComplete', 'Upgrade Complete')}>
<Text size="sm">
{t(
'payment.upgradeCompleteMessage',
'Your subscription has been upgraded successfully. Your existing license key has been updated.'
)}
</Text>
</Alert>
)}
{pollingStatus === 'timeout' && (
<Alert color="yellow" title={t('payment.licenseDelayed', 'License Key Processing')}>
<Text size="sm">
{t(
'payment.licenseDelayedMessage',
'Your license key is being generated. Please check your email shortly or contact support.'
)}
</Text>
</Alert>
)}
{pollingStatus === 'ready' && (
<Text size="xs" c="dimmed">
{t('payment.canCloseWindow', 'You can now close this window.')}
</Text>
)}
</Stack>
</Alert>
);
case 'error':
return (
<Alert color="red" title={t('payment.error', 'Payment Error')}>
<Stack gap="md">
<Text size="sm">{state.error}</Text>
<Button variant="outline" onClick={handleClose}>
{t('common.close', 'Close')}
</Button>
</Stack>
</Alert>
);
default:
return null;
}
};
return (
<Modal
opened={opened}
onClose={handleClose}
title={
<Text fw={600} size="lg">
{t('payment.upgradeTitle', 'Upgrade to {{planName}}', { planName: planGroup.name })}
</Text>
}
size="90%"
centered
withCloseButton={true}
closeOnEscape={true}
closeOnClickOutside={false}
zIndex={Z_INDEX_OVER_CONFIG_MODAL}
styles={{
body: {
minHeight: '85vh',
},
content: {
maxHeight: '95vh',
},
}}
>
{renderContent()}
</Modal>
);
};
export default StripeCheckout;
@@ -0,0 +1,144 @@
import React, { useState, useEffect } from 'react';
import { Group, Text, Button, ActionIcon, Paper } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import { useAuth } from '@app/auth/UseSession';
import { useCheckout } from '@app/contexts/CheckoutContext';
import { useLicense } from '@app/contexts/LicenseContext';
import { mapLicenseToTier } from '@app/services/licenseService';
import LocalIcon from '@app/components/shared/LocalIcon';
import { isSupabaseConfigured } from '@app/services/supabaseClient';
/**
* UpgradeBanner - Dismissable top banner encouraging users to upgrade
*
* This component demonstrates:
* - How to check authentication status with useAuth()
* - How to check license status with licenseService
* - How to open checkout modal with useCheckout()
* - How to persist dismissal state with localStorage
*
* To remove this banner:
* 1. Remove the import and component from AppProviders.tsx
* 2. Delete this file
*/
const UpgradeBanner: React.FC = () => {
const { t } = useTranslation();
const { user } = useAuth();
const { openCheckout } = useCheckout();
const { licenseInfo, loading: licenseLoading } = useLicense();
const [isVisible, setIsVisible] = useState(false);
// Check if user should see the banner
useEffect(() => {
// Don't show if not logged in
if (!user) {
setIsVisible(false);
return;
}
// Don't show if Supabase is not configured (no checkout available)
if (!isSupabaseConfigured) {
setIsVisible(false);
return;
}
// Don't show while license is loading
if (licenseLoading) {
return;
}
// Check if banner was dismissed
const dismissed = localStorage.getItem('upgradeBannerDismissed');
if (dismissed === 'true') {
setIsVisible(false);
return;
}
// Check license status from global context
const tier = mapLicenseToTier(licenseInfo);
// Show banner only for free tier users
if (tier === 'free' || tier === null) {
setIsVisible(true);
} else {
// Auto-hide banner if user upgrades
setIsVisible(false);
}
}, [user, licenseInfo, licenseLoading]);
// Handle dismiss
const handleDismiss = () => {
localStorage.setItem('upgradeBannerDismissed', 'true');
setIsVisible(false);
};
// Handle upgrade button click
const handleUpgrade = () => {
openCheckout('server', {
currency: 'gbp',
minimumSeats: 1,
onSuccess: () => {
// Banner will auto-hide on next render when license is detected
setIsVisible(false);
},
});
};
// Don't render anything if loading or not visible
if (licenseLoading || !isVisible) {
return null;
}
return (
<Paper
shadow="sm"
p="md"
style={{
position: 'fixed',
top: 0,
left: 0,
right: 0,
zIndex: 1000,
borderRadius: 0,
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
color: 'white',
}}
>
<Group justify="space-between" wrap="nowrap">
<Group gap="md" wrap="nowrap">
<LocalIcon icon="stars-rounded" width="1.5rem" height="1.5rem" />
<div>
<Text size="sm" fw={600}>
{t('upgradeBanner.title', 'Upgrade to Server Plan')}
</Text>
<Text size="xs" opacity={0.9}>
{t('upgradeBanner.message', 'Get the most out of Stirling PDF with unlimited users and advanced features')}
</Text>
</div>
</Group>
<Group gap="xs" wrap="nowrap">
<Button
variant="white"
size="sm"
onClick={handleUpgrade}
leftSection={<LocalIcon icon="upgrade-rounded" width="1rem" height="1rem" />}
>
{t('upgradeBanner.upgradeButton', 'Upgrade Now')}
</Button>
<ActionIcon
variant="subtle"
color="white"
size="lg"
onClick={handleDismiss}
aria-label={t('upgradeBanner.dismiss', 'Dismiss banner')}
>
<LocalIcon icon="close-rounded" width="1.25rem" height="1.25rem" />
</ActionIcon>
</Group>
</Group>
</Paper>
);
};
export default UpgradeBanner;
@@ -10,6 +10,7 @@ import AdminDatabaseSection from '@app/components/shared/config/configSections/A
import AdminAdvancedSection from '@app/components/shared/config/configSections/AdminAdvancedSection';
import AdminLegalSection from '@app/components/shared/config/configSections/AdminLegalSection';
import AdminPremiumSection from '@app/components/shared/config/configSections/AdminPremiumSection';
import AdminPlanSection from '@app/components/shared/config/configSections/AdminPlanSection';
import AdminFeaturesSection from '@app/components/shared/config/configSections/AdminFeaturesSection';
import AdminEndpointsSection from '@app/components/shared/config/configSections/AdminEndpointsSection';
import AdminAuditSection from '@app/components/shared/config/configSections/AdminAuditSection';
@@ -136,6 +137,14 @@ export const createConfigNavSections = (
disabled: requiresLogin,
disabledTooltip: requiresLogin ? 'Enable login mode first' : undefined
},
{
key: 'adminPlan',
label: 'Plan',
icon: 'star-rounded',
component: <AdminPlanSection />,
disabled: requiresLogin,
disabledTooltip: requiresLogin ? 'Enable login mode first' : undefined
},
{
key: 'adminAudit',
label: 'Audit',
@@ -0,0 +1,240 @@
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 { useCheckout } from '@app/contexts/CheckoutContext';
import { useLicense } from '@app/contexts/LicenseContext';
import AvailablePlansSection from '@app/components/shared/config/configSections/plan/AvailablePlansSection';
import StaticPlanSection from '@app/components/shared/config/configSections/plan/StaticPlanSection';
import { alert } from '@app/components/toast';
import LocalIcon from '@app/components/shared/LocalIcon';
import { Z_INDEX_OVER_CONFIG_MODAL } from '@app/styles/zIndex';
import { ManageBillingButton } from '@app/components/shared/ManageBillingButton';
import { isSupabaseConfigured } from '@app/services/supabaseClient';
const AdminPlanSection: React.FC = () => {
const { t } = useTranslation();
const { openCheckout } = useCheckout();
const { licenseInfo, refetchLicense } = useLicense();
const [currency, setCurrency] = useState<string>('gbp');
const [useStaticVersion, setUseStaticVersion] = useState(false);
const [showLicenseKey, setShowLicenseKey] = useState(false);
const [licenseKeyInput, setLicenseKeyInput] = useState<string>('');
const [savingLicense, setSavingLicense] = useState(false);
const { plans, loading, error, refetch } = usePlans(currency);
// Check if we should use static version
useEffect(() => {
// Check if Stripe and Supabase are configured
const stripeKey = import.meta.env.VITE_STRIPE_PUBLISHABLE_KEY;
if (!stripeKey || !isSupabaseConfigured || error) {
setUseStaticVersion(true);
}
}, [error]);
const handleSaveLicense = async () => {
try {
setSavingLicense(true);
// Allow empty string to clear/remove license
const response = await licenseService.saveLicenseKey(licenseKeyInput.trim());
if (response.success) {
// Refresh license context to update all components
await refetchLicense();
alert({
alertType: 'success',
title: t('admin.settings.premium.key.success', 'License Key Saved'),
body: t('admin.settings.premium.key.successMessage', 'Your license key has been activated successfully. No restart required.'),
});
// Clear input
setLicenseKeyInput('');
} else {
alert({
alertType: 'error',
title: t('admin.error', 'Error'),
body: response.error || t('admin.settings.saveError', 'Failed to save license key'),
});
}
} catch (error) {
console.error('Failed to save license key:', error);
alert({
alertType: 'error',
title: t('admin.error', 'Error'),
body: t('admin.settings.saveError', 'Failed to save license key'),
});
} finally {
setSavingLicense(false);
}
};
const currencyOptions = [
{ value: 'gbp', label: 'British pound (GBP, £)' },
{ value: 'usd', label: 'US dollar (USD, $)' },
{ value: 'eur', label: 'Euro (EUR, €)' },
{ value: 'cny', label: 'Chinese yuan (CNY, ¥)' },
{ value: 'inr', label: 'Indian rupee (INR, ₹)' },
{ value: 'brl', label: 'Brazilian real (BRL, R$)' },
{ value: 'idr', label: 'Indonesian rupiah (IDR, Rp)' },
];
const handleUpgradeClick = useCallback(
(planGroup: PlanTierGroup) => {
// Only allow upgrades for server and enterprise tiers
if (planGroup.tier === 'free') {
return;
}
// Use checkout context to open checkout modal
openCheckout(planGroup.tier, {
currency,
onSuccess: () => {
// Refetch plans after successful payment
// License context will auto-update
refetch();
},
});
},
[openCheckout, currency, refetch]
);
// Show static version if Stripe is not configured or there's an error
if (useStaticVersion) {
return <StaticPlanSection currentLicenseInfo={licenseInfo ?? undefined} />;
}
// Early returns after all hooks are called
if (loading) {
return (
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', padding: '2rem 0' }}>
<Loader size="lg" />
</div>
);
}
if (error) {
// Fallback to static version on error
return <StaticPlanSection currentLicenseInfo={licenseInfo ?? undefined} />;
}
if (!plans || plans.length === 0) {
return (
<Alert color="yellow" title="No data available">
Plans data is not available at the moment.
</Alert>
);
}
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: '2rem' }}>
{/* Currency Selection & Manage Subscription */}
<Paper withBorder p="md" radius="md">
<Stack gap="md">
<Group justify="space-between" align="center">
<Text size="lg" fw={600}>
{t('plan.currency', 'Currency')}
</Text>
<Select
value={currency}
onChange={(value) => setCurrency(value || 'gbp')}
data={currencyOptions}
searchable
clearable={false}
w={300}
comboboxProps={{ withinPortal: true, zIndex: Z_INDEX_OVER_CONFIG_MODAL }}
/>
</Group>
{/* Manage Subscription Button - Only show if user has active license and Supabase is configured */}
{licenseInfo?.licenseKey && isSupabaseConfigured && (
<Group justify="space-between" align="center">
<Text size="sm" c="dimmed">
{t('plan.manageSubscription.description', 'Manage your subscription, billing, and payment methods')}
</Text>
<ManageBillingButton />
</Group>
)}
</Stack>
</Paper>
<AvailablePlansSection
plans={plans}
currentLicenseInfo={licenseInfo}
onUpgradeClick={handleUpgradeClick}
/>
<Divider />
{/* License Key Section */}
<div>
<Button
variant="subtle"
leftSection={<LocalIcon icon={showLicenseKey ? "expand-less-rounded" : "expand-more-rounded"} width="1.25rem" height="1.25rem" />}
onClick={() => setShowLicenseKey(!showLicenseKey)}
>
{t('admin.settings.premium.licenseKey.toggle', 'Got a license key or certificate file?')}
</Button>
<Collapse in={showLicenseKey} mt="md">
<Stack gap="md">
<Alert
variant="light"
color="blue"
icon={<LocalIcon icon="info-rounded" width="1rem" height="1rem" />}
>
<Text size="sm">
{t('admin.settings.premium.licenseKey.info', 'If you have a license key or certificate file from a direct purchase, you can enter it here to activate premium or enterprise features.')}
</Text>
</Alert>
{/* Severe warning if license already exists */}
{licenseInfo?.licenseKey && (
<Alert
variant="light"
color="red"
icon={<LocalIcon icon="warning-rounded" width="1rem" height="1rem" />}
title={t('admin.settings.premium.key.overwriteWarning.title', '⚠️ Warning: Existing License Detected')}
>
<Stack gap="xs">
<Text size="sm" fw={600}>
{t('admin.settings.premium.key.overwriteWarning.line1', 'Overwriting your current license key cannot be undone.')}
</Text>
<Text size="sm">
{t('admin.settings.premium.key.overwriteWarning.line2', 'Your previous license will be permanently lost unless you have backed it up elsewhere.')}
</Text>
<Text size="sm" fw={500}>
{t('admin.settings.premium.key.overwriteWarning.line3', 'Important: Keep license keys private and secure. Never share them publicly.')}
</Text>
</Stack>
</Alert>
)}
<Paper withBorder p="md" radius="md">
<Stack gap="md">
<TextInput
label={t('admin.settings.premium.key.label', 'License Key')}
description={t('admin.settings.premium.key.description', 'Enter your premium or enterprise license key. Premium features will be automatically enabled when a key is provided.')}
value={licenseKeyInput}
onChange={(e) => setLicenseKeyInput(e.target.value)}
placeholder={licenseInfo?.licenseKey || '00000000-0000-0000-0000-000000000000'}
type="password"
disabled={savingLicense}
/>
<Group justify="flex-end">
<Button onClick={handleSaveLicense} loading={savingLicense} size="sm">
{t('admin.settings.save', 'Save Changes')}
</Button>
</Group>
</Stack>
</Paper>
</Stack>
</Collapse>
</div>
</div>
);
};
export default AdminPlanSection;
@@ -0,0 +1,109 @@
import React, { useState, useMemo } from 'react';
import { Button, Collapse } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import licenseService, { PlanTier, PlanTierGroup, LicenseInfo, mapLicenseToTier } from '@app/services/licenseService';
import PlanCard from '@app/components/shared/config/configSections/plan/PlanCard';
import FeatureComparisonTable from '@app/components/shared/config/configSections/plan/FeatureComparisonTable';
interface AvailablePlansSectionProps {
plans: PlanTier[];
currentPlanId?: string;
currentLicenseInfo?: LicenseInfo | null;
onUpgradeClick: (planGroup: PlanTierGroup) => void;
}
const AvailablePlansSection: React.FC<AvailablePlansSectionProps> = ({
plans,
currentLicenseInfo,
onUpgradeClick,
}) => {
const { t } = useTranslation();
const [showComparison, setShowComparison] = useState(false);
// Group plans by tier (Free, Server, Enterprise)
const groupedPlans = useMemo(() => {
return licenseService.groupPlansByTier(plans);
}, [plans]);
// 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 => {
// 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<string, number> = {
'enterprise': 3,
'server': 2,
'free': 1
};
const currentLevel = tierHierarchy[currentTier] || 0;
const targetLevel = tierHierarchy[tierGroup.tier] || 0;
return currentLevel > targetLevel;
};
return (
<div>
<h3 style={{ margin: 0, color: 'var(--mantine-color-text)', fontSize: '1rem' }}>
{t('plan.availablePlans.title', 'Available Plans')}
</h3>
<p
style={{
margin: '0.25rem 0 1rem 0',
color: 'var(--mantine-color-dimmed)',
fontSize: '0.875rem',
}}
>
{t('plan.availablePlans.subtitle', 'Choose the plan that fits your needs')}
</p>
<div
style={{
display: 'grid',
gridTemplateColumns: 'repeat(3, 1fr)',
gap: '1rem',
marginBottom: '1rem',
}}
>
{groupedPlans.map((group) => (
<PlanCard
key={group.tier}
planGroup={group}
isCurrentTier={isCurrentTier(group)}
isDowngrade={isDowngrade(group)}
currentLicenseInfo={currentLicenseInfo}
onUpgradeClick={onUpgradeClick}
/>
))}
</div>
<div style={{ textAlign: 'center' }}>
<Button variant="subtle" onClick={() => setShowComparison(!showComparison)}>
{showComparison
? t('plan.hideComparison', 'Hide Feature Comparison')
: t('plan.showComparison', 'Compare All Features')}
</Button>
</div>
<Collapse in={showComparison}>
<FeatureComparisonTable plans={groupedPlans} />
</Collapse>
</div>
);
};
export default AvailablePlansSection;
@@ -0,0 +1,93 @@
import React from 'react';
import { Card, Badge, Text } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import { PlanFeature } from '@app/services/licenseService';
interface PlanWithFeatures {
name: string;
features: PlanFeature[];
popular?: boolean;
tier?: string;
}
interface FeatureComparisonTableProps {
plans: PlanWithFeatures[];
}
const FeatureComparisonTable: React.FC<FeatureComparisonTableProps> = ({ plans }) => {
const { t } = useTranslation();
return (
<Card padding="lg" radius="md" withBorder style={{ marginTop: '1rem' }}>
<Text size="lg" fw={600} mb="md">
{t('plan.featureComparison', 'Feature Comparison')}
</Text>
<div style={{ overflowX: 'auto' }}>
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
<thead>
<tr style={{ borderBottom: '2px solid var(--mantine-color-gray-3)' }}>
<th style={{ textAlign: 'left', padding: '0.75rem' }}>
{t('plan.feature.title', 'Feature')}
</th>
{plans.map((plan, index) => (
<th
key={plan.tier || plan.name || index}
style={{
textAlign: 'center',
padding: '0.75rem',
minWidth: '8rem',
position: 'relative'
}}
>
{plan.name}
{plan.popular && (
<Badge
color="blue"
variant="filled"
size="xs"
style={{
position: 'absolute',
top: '0.5rem',
right: '0.5rem',
}}
>
{t('plan.popular', 'Popular')}
</Badge>
)}
</th>
))}
</tr>
</thead>
<tbody>
{plans[0]?.features.map((_, featureIndex) => (
<tr
key={featureIndex}
style={{ borderBottom: '1px solid var(--mantine-color-gray-3)' }}
>
<td style={{ padding: '0.75rem' }}>
{plans[0].features[featureIndex].name}
</td>
{plans.map((plan, planIndex) => (
<td key={planIndex} style={{ textAlign: 'center', padding: '0.75rem' }}>
{plan.features[featureIndex]?.included ? (
<Text c="green" fw={600} size="lg">
</Text>
) : (
<Text c="gray" size="sm">
</Text>
)}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
</Card>
);
};
export default FeatureComparisonTable;
@@ -0,0 +1,202 @@
import React from 'react';
import { Button, Card, Badge, Text, Stack, Divider } from '@mantine/core';
import { useTranslation } from 'react-i18next';
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<PlanCardProps> = ({ planGroup, isCurrentTier, isDowngrade, currentLicenseInfo, onUpgradeClick }) => {
const { t } = useTranslation();
// Render Free plan
if (planGroup.tier === 'free') {
return (
<Card
padding="lg"
radius="md"
withBorder
style={{
position: 'relative',
display: 'flex',
flexDirection: 'column',
minHeight: '400px',
borderColor: isCurrentTier ? 'var(--mantine-color-green-6)' : undefined,
borderWidth: isCurrentTier ? '2px' : undefined,
}}
>
{isCurrentTier && (
<Badge
color="green"
variant="filled"
size="sm"
style={{ position: 'absolute', top: '1rem', right: '1rem' }}
>
{t('plan.current', 'Current Plan')}
</Badge>
)}
<Stack gap="md" style={{ height: '100%' }}>
<div>
<Text size="xl" fw={700} mb="xs">
{planGroup.name}
</Text>
<Text size="xs" c="dimmed" mb="xs" style={{ opacity: 0 }}>
{t('plan.from', 'From')}
</Text>
<Text size="2.5rem" fw={700} style={{ lineHeight: 1 }}>
£0
</Text>
<Text size="sm" c="dimmed" mt="xs">
{t('plan.free.forever', 'Forever free')}
</Text>
</div>
<Divider />
<Stack gap="xs">
{planGroup.highlights.map((highlight, index) => (
<Text key={index} size="sm" c="dimmed">
{highlight}
</Text>
))}
</Stack>
<div style={{ flexGrow: 1 }} />
<Button variant="filled" disabled fullWidth>
{isCurrentTier
? t('plan.current', 'Current Plan')
: t('plan.free.included', 'Included')}
</Button>
</Stack>
</Card>
);
}
// Render Server or Enterprise plans
const { monthly, yearly } = planGroup;
const isEnterprise = planGroup.tier === 'enterprise';
// Calculate "From" pricing - show yearly price divided by 12 for lowest monthly equivalent
let displayPrice = monthly?.price || 0;
let displaySeatPrice = monthly?.seatPrice;
let displayCurrency = monthly?.currency || '£';
if (yearly) {
displayPrice = Math.round(yearly.price / 12);
displaySeatPrice = yearly.seatPrice ? Math.round(yearly.seatPrice / 12) : undefined;
displayCurrency = yearly.currency;
}
return (
<Card
padding="lg"
radius="md"
withBorder
style={{
position: 'relative',
display: 'flex',
flexDirection: 'column',
minHeight: '400px',
borderColor: isCurrentTier ? 'var(--mantine-color-green-6)' : undefined,
borderWidth: isCurrentTier ? '2px' : undefined,
}}
>
{isCurrentTier ? (
<Badge
color="green"
variant="filled"
size="sm"
style={{ position: 'absolute', top: '1rem', right: '1rem' }}
>
{t('plan.current', 'Current Plan')}
</Badge>
) : planGroup.popular ? (
<Badge
variant="filled"
size="sm"
style={{ position: 'absolute', top: '1rem', right: '1rem' }}
>
{t('plan.popular', 'Popular')}
</Badge>
) : null}
<Stack gap="md" style={{ height: '100%' }}>
{/* Tier Name */}
<div>
<Text size="xl" fw={700} mb="xs">
{planGroup.name}
</Text>
<Text size="xs" c="dimmed" mb="xs">
{t('plan.from', 'From')}
</Text>
{/* Price */}
{isEnterprise && displaySeatPrice !== undefined ? (
<>
<Text size="2.5rem" fw={700} style={{ lineHeight: 1 }}>
{displayCurrency}{displayPrice}
</Text>
<Text size="sm" c="dimmed" mt="xs">
+ {displayCurrency}{displaySeatPrice}/seat {t('plan.perMonth', '/month')}
</Text>
</>
) : (
<>
<Text size="2.5rem" fw={700} style={{ lineHeight: 1 }}>
{displayCurrency}{displayPrice}
</Text>
<Text size="sm" c="dimmed" mt="xs">
{t('plan.perMonth', '/month')}
</Text>
</>
)}
{/* Show seat count for enterprise plans when current */}
{isEnterprise && isCurrentTier && currentLicenseInfo && currentLicenseInfo.maxUsers > 0 && (
<Text size="sm" c="green" fw={500} mt="xs">
{t('plan.licensedSeats', 'Licensed: {{count}} seats', { count: currentLicenseInfo.maxUsers })}
</Text>
)}
</div>
<Divider />
{/* Highlights */}
<Stack gap="xs">
{planGroup.highlights.map((highlight, index) => (
<Text key={index} size="sm" c="dimmed">
{highlight}
</Text>
))}
</Stack>
<div style={{ flexGrow: 1 }} />
{/* Single Upgrade Button */}
<Button
variant={isCurrentTier || isDowngrade ? 'light' : 'filled'}
fullWidth
onClick={() => onUpgradeClick(planGroup)}
disabled={isCurrentTier || isDowngrade}
>
{isCurrentTier
? t('plan.current', 'Current Plan')
: isDowngrade
? t('plan.includedInCurrent', 'Included in Your Plan')
: isEnterprise
? t('plan.selectPlan', 'Select Plan')
: t('plan.upgrade', 'Upgrade')}
</Button>
</Stack>
</Card>
);
};
export default PlanCard;
@@ -0,0 +1,338 @@
import React, { useState, useEffect } from 'react';
import { Card, Text, Group, Stack, Badge, Button, Collapse, Alert, TextInput, Paper, Loader, Divider } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import LocalIcon from '@app/components/shared/LocalIcon';
import RestartConfirmationModal from '@app/components/shared/config/RestartConfirmationModal';
import { useRestartServer } from '@app/components/shared/config/useRestartServer';
import { useAdminSettings } from '@app/hooks/useAdminSettings';
import PendingBadge from '@app/components/shared/config/PendingBadge';
import { alert } from '@app/components/toast';
import { LicenseInfo, mapLicenseToTier } from '@app/services/licenseService';
import { PLAN_FEATURES, PLAN_HIGHLIGHTS } from '@app/constants/planConstants';
import FeatureComparisonTable from '@app/components/shared/config/configSections/plan/FeatureComparisonTable';
interface PremiumSettingsData {
key?: string;
enabled?: boolean;
}
interface StaticPlanSectionProps {
currentLicenseInfo?: LicenseInfo;
}
const StaticPlanSection: React.FC<StaticPlanSectionProps> = ({ currentLicenseInfo }) => {
const { t } = useTranslation();
const [showLicenseKey, setShowLicenseKey] = useState(false);
const [showComparison, setShowComparison] = useState(false);
// Premium/License key management
const { restartModalOpened, showRestartModal, closeRestartModal, restartServer } = useRestartServer();
const {
settings: premiumSettings,
setSettings: setPremiumSettings,
loading: premiumLoading,
saving: premiumSaving,
fetchSettings: fetchPremiumSettings,
saveSettings: savePremiumSettings,
isFieldPending,
} = useAdminSettings<PremiumSettingsData>({
sectionName: 'premium',
});
useEffect(() => {
fetchPremiumSettings();
}, []);
const handleSaveLicense = async () => {
try {
await savePremiumSettings();
showRestartModal();
} catch (_error) {
alert({
alertType: 'error',
title: t('admin.error', 'Error'),
body: t('admin.settings.saveError', 'Failed to save settings'),
});
}
};
const staticPlans = [
{
id: 'free',
name: t('plan.free.name', 'Free'),
price: 0,
currency: '£',
period: '',
highlights: PLAN_HIGHLIGHTS.FREE,
features: PLAN_FEATURES.FREE,
maxUsers: 5,
},
{
id: 'server',
name: 'Server',
price: 0,
currency: '',
period: '',
popular: false,
highlights: PLAN_HIGHLIGHTS.SERVER_MONTHLY,
features: PLAN_FEATURES.SERVER,
maxUsers: 'Unlimited users',
},
{
id: 'enterprise',
name: t('plan.enterprise.name', 'Enterprise'),
price: 0,
currency: '',
period: '',
highlights: PLAN_HIGHLIGHTS.ENTERPRISE_MONTHLY,
features: PLAN_FEATURES.ENTERPRISE,
maxUsers: 'Custom',
},
];
const getCurrentPlan = () => {
const tier = mapLicenseToTier(currentLicenseInfo || null);
if (tier === 'enterprise') return staticPlans[2];
if (tier === 'server') return staticPlans[1];
return staticPlans[0]; // free
};
const currentPlan = getCurrentPlan();
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: '2rem' }}>
{/* Current Plan Section */}
<div>
<h3 style={{ margin: 0, color: 'var(--mantine-color-text)', fontSize: '1rem' }}>
{t('plan.activePlan.title', 'Active Plan')}
</h3>
<p
style={{
margin: '0.25rem 0 1rem 0',
color: 'var(--mantine-color-dimmed)',
fontSize: '0.875rem',
}}
>
{t('plan.activePlan.subtitle', 'Your current subscription details')}
</p>
<Card padding="lg" radius="md" withBorder>
<Group justify="space-between" align="center">
<Stack gap="xs">
<Group gap="sm">
<Text size="lg" fw={600}>
{currentPlan.name}
</Text>
<Badge color="green" variant="light">
{t('subscription.status.active', 'Active')}
</Badge>
</Group>
{currentLicenseInfo && (
<Text size="sm" c="dimmed">
{t('plan.static.maxUsers', 'Max Users')}: {currentLicenseInfo.maxUsers}
</Text>
)}
</Stack>
<div style={{ textAlign: 'right' }}>
<Text size="xl" fw={700}>
{currentPlan.price === 0 ? t('plan.free.name', 'Free') : `${currentPlan.currency}${currentPlan.price}${currentPlan.period}`}
</Text>
</div>
</Group>
</Card>
</div>
{/* Available Plans */}
<div>
<h3 style={{ margin: 0, color: 'var(--mantine-color-text)', fontSize: '1rem' }}>
{t('plan.availablePlans.title', 'Available Plans')}
</h3>
<p
style={{
margin: '0.25rem 0 1rem 0',
color: 'var(--mantine-color-dimmed)',
fontSize: '0.875rem',
}}
>
{t('plan.static.contactToUpgrade', 'Contact us to upgrade or customize your plan')}
</p>
<div
style={{
display: 'grid',
gridTemplateColumns: 'repeat(3, 1fr)',
gap: '1rem',
paddingBottom: '1rem',
}}
>
{staticPlans.map((plan) => (
<Card
key={plan.id}
padding="lg"
radius="md"
withBorder
style={{
position: 'relative',
display: 'flex',
flexDirection: 'column',
borderColor: plan.id === currentPlan.id ? 'var(--mantine-color-green-6)' : undefined,
borderWidth: plan.id === currentPlan.id ? '2px' : undefined,
}}
>
{plan.id === currentPlan.id && (
<Badge
color="green"
variant="filled"
size="sm"
style={{ position: 'absolute', top: '1rem', right: '1rem' }}
>
{t('plan.current', 'Current Plan')}
</Badge>
)}
{plan.popular && plan.id !== currentPlan.id && (
<Badge
variant="filled"
size="xs"
style={{ position: 'absolute', top: '0.5rem', right: '0.5rem' }}
>
{t('plan.popular', 'Popular')}
</Badge>
)}
<Stack gap="md" style={{ height: '100%' }}>
<div>
<Text size="lg" fw={600}>
{plan.name}
</Text>
<Group gap="xs" style={{ alignItems: 'baseline' }}>
<Text size="xl" fw={700} style={{ fontSize: '2rem' }}>
{plan.price === 0 && plan.id !== 'free'
? t('plan.customPricing', 'Custom')
: plan.price === 0
? t('plan.free.name', 'Free')
: `${plan.currency}${plan.price}`}
</Text>
{plan.period && (
<Text size="sm" c="dimmed">
{plan.period}
</Text>
)}
</Group>
<Text size="xs" c="dimmed" mt="xs">
{typeof plan.maxUsers === 'string'
? plan.maxUsers
: `${t('plan.static.upTo', 'Up to')} ${plan.maxUsers} ${t('workspace.people.license.users', 'users')}`}
</Text>
</div>
<Stack gap="xs">
{plan.highlights.map((highlight, index) => (
<Text key={index} size="sm" c="dimmed">
{highlight}
</Text>
))}
</Stack>
<div style={{ flexGrow: 1 }} />
<Button
variant={plan.id === currentPlan.id ? 'light' : 'filled'}
disabled={plan.id === currentPlan.id}
fullWidth
onClick={() =>
window.open('https://www.stirling.com/contact', '_blank')
}
>
{plan.id === currentPlan.id
? t('plan.current', 'Current Plan')
: t('plan.contact', 'Contact Us')}
</Button>
</Stack>
</Card>
))}
</div>
{/* Feature Comparison Toggle */}
<div style={{ textAlign: 'center', marginTop: '1rem' }}>
<Button variant="subtle" onClick={() => setShowComparison(!showComparison)}>
{showComparison
? t('plan.hideComparison', 'Hide Feature Comparison')
: t('plan.showComparison', 'Compare All Features')}
</Button>
</div>
{/* Feature Comparison Table */}
<Collapse in={showComparison}>
<FeatureComparisonTable plans={staticPlans} />
</Collapse>
</div>
<Divider />
{/* License Key Section */}
<div>
<Button
variant="subtle"
leftSection={<LocalIcon icon={showLicenseKey ? "expand-less-rounded" : "expand-more-rounded"} width="1.25rem" height="1.25rem" />}
onClick={() => setShowLicenseKey(!showLicenseKey)}
>
{t('admin.settings.premium.licenseKey.toggle', 'Got a license key or certificate file?')}
</Button>
<Collapse in={showLicenseKey} mt="md">
<Stack gap="md">
<Alert
variant="light"
color="blue"
icon={<LocalIcon icon="info-rounded" width="1rem" height="1rem" />}
>
<Text size="sm">
{t('admin.settings.premium.licenseKey.info', 'If you have a license key or certificate file from a direct purchase, you can enter it here to activate premium or enterprise features.')}
</Text>
</Alert>
{premiumLoading ? (
<Stack align="center" justify="center" h={100}>
<Loader size="md" />
</Stack>
) : (
<Paper withBorder p="md" radius="md">
<Stack gap="md">
<div>
<TextInput
label={
<Group gap="xs">
<span>{t('admin.settings.premium.key.label', 'License Key')}</span>
<PendingBadge show={isFieldPending('key')} />
</Group>
}
description={t('admin.settings.premium.key.description', 'Enter your premium or enterprise license key. Premium features will be automatically enabled when a key is provided.')}
value={premiumSettings.key || ''}
onChange={(e) => setPremiumSettings({ ...premiumSettings, key: e.target.value })}
placeholder="00000000-0000-0000-0000-000000000000"
/>
</div>
<Group justify="flex-end">
<Button onClick={handleSaveLicense} loading={premiumSaving} size="sm">
{t('admin.settings.save', 'Save Changes')}
</Button>
</Group>
</Stack>
</Paper>
)}
</Stack>
</Collapse>
</div>
{/* Restart Confirmation Modal */}
<RestartConfirmationModal
opened={restartModalOpened}
onClose={closeRestartModal}
onRestart={restartServer}
/>
</div>
);
};
export default StaticPlanSection;
@@ -0,0 +1,97 @@
import { PlanFeature } from '@app/services/licenseService';
/**
* Shared plan feature definitions for Stirling PDF Self-Hosted
* Used by both dynamic (Stripe) and static (fallback) plan displays
*/
export const PLAN_FEATURES = {
FREE: [
{ name: 'Self-hosted deployment', included: true },
{ name: 'All PDF operations', included: true },
{ name: 'Secure Login Support', included: true },
{ name: 'Community support', included: true },
{ name: 'Regular updates', included: true },
{ name: 'up to 5 users', included: true },
{ name: 'Unlimited users', included: false },
{ name: 'Google drive integration', included: false },
{ name: 'External Database', included: false },
{ name: 'Editing text in pdfs', included: false },
{ name: 'Users limited to seats', included: false },
{ name: 'SSO', included: false },
{ name: 'Auditing', included: false },
{ name: 'Usage tracking', included: false },
{ name: 'Prometheus Support', included: false },
{ name: 'Custom PDF metadata', included: false },
] as PlanFeature[],
SERVER: [
{ name: 'Self-hosted deployment', included: true },
{ name: 'All PDF operations', included: true },
{ name: 'Secure Login Support', included: true },
{ name: 'Community support', included: true },
{ name: 'Regular updates', included: true },
{ name: 'Up to 5 users', included: false },
{ name: 'Unlimited users', included: true },
{ name: 'Google drive integration', included: true },
{ name: 'External Database', included: true },
{ name: 'Editing text in pdfs', included: true },
{ name: 'Users limited to seats', included: false },
{ name: 'SSO', included: false },
{ name: 'Auditing', included: false },
{ name: 'Usage tracking', included: false },
{ name: 'Prometheus Support', included: false },
{ name: 'Custom PDF metadata', included: false },
] as PlanFeature[],
ENTERPRISE: [
{ name: 'Self-hosted deployment', included: true },
{ name: 'All PDF operations', included: true },
{ name: 'Secure Login Support', included: true },
{ name: 'Community support', included: true },
{ name: 'Regular updates', included: true },
{ name: 'up to 5 users', included: false },
{ name: 'Unlimited users', included: false },
{ name: 'Google drive integration', included: true },
{ name: 'External Database', included: true },
{ name: 'Editing text in pdfs', included: true },
{ name: 'Users limited to seats', included: true },
{ name: 'SSO', included: true },
{ name: 'Auditing', included: true },
{ name: 'Usage tracking', included: true },
{ name: 'Prometheus Support', included: true },
{ name: 'Custom PDF metadata', included: true },
] as PlanFeature[],
} as const;
export const PLAN_HIGHLIGHTS = {
FREE: [
'Up to 5 users',
'Self-hosted',
'All basic features'
],
SERVER_MONTHLY: [
'Self-hosted on your infrastructure',
'Unlimited users',
'Advanced integrations',
'Cancel anytime'
],
SERVER_YEARLY: [
'Self-hosted on your infrastructure',
'Unlimited users',
'Advanced integrations',
'Save with annual billing'
],
ENTERPRISE_MONTHLY: [
'Enterprise features (SSO, Auditing)',
'Usage tracking & Prometheus',
'Custom PDF metadata',
'Per-seat licensing'
],
ENTERPRISE_YEARLY: [
'Enterprise features (SSO, Auditing)',
'Usage tracking & Prometheus',
'Custom PDF metadata',
'Save with annual billing'
]
} as const;
@@ -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;
};
@@ -0,0 +1,44 @@
import { useState, useEffect } from 'react';
import licenseService, {
PlanTier,
PlansResponse,
} from '@app/services/licenseService';
export interface UsePlansReturn {
plans: PlanTier[];
loading: boolean;
error: string | null;
refetch: () => Promise<void>;
}
export const usePlans = (currency: string = 'gbp'): UsePlansReturn => {
const [plans, setPlans] = useState<PlanTier[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const fetchPlans = async () => {
try {
setLoading(true);
setError(null);
const data: PlansResponse = await licenseService.getPlans(currency);
setPlans(data.plans);
} catch (err) {
console.error('Error fetching plans:', err);
setError(err instanceof Error ? err.message : 'Failed to fetch plans');
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchPlans();
}, [currency]);
return {
plans,
loading,
error,
refetch: fetchPlans,
};
};
@@ -0,0 +1,475 @@
import apiClient from '@app/services/apiClient';
import { supabase, isSupabaseConfigured } from '@app/services/supabaseClient';
import { getCheckoutMode } from '@app/utils/protocolDetection';
import { PLAN_FEATURES, PLAN_HIGHLIGHTS } from '@app/constants/planConstants';
export interface PlanFeature {
name: string;
included: boolean;
}
export interface PlanTier {
id: string;
name: string;
price: number;
currency: string;
period: string;
popular?: boolean;
features: PlanFeature[];
highlights: readonly string[];
isContactOnly?: boolean;
seatPrice?: number; // Per-seat price for enterprise plans
requiresSeats?: boolean; // Flag indicating seat selection is needed
lookupKey: string; // Stripe lookup key for this plan
}
export interface PlanTierGroup {
tier: 'free' | 'server' | 'enterprise';
name: string;
monthly: PlanTier | null;
yearly: PlanTier | null;
features: PlanFeature[];
highlights: readonly string[];
popular?: boolean;
}
export interface PlansResponse {
plans: PlanTier[];
}
export interface CheckoutSessionRequest {
lookup_key: string; // Stripe lookup key (e.g., 'selfhosted:server:monthly')
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;
cancelUrl?: string;
}
export interface CheckoutSessionResponse {
clientSecret: string;
sessionId: string;
url?: string; // URL for hosted checkout (when not using HTTPS)
}
export interface BillingPortalResponse {
url: string;
}
export interface InstallationIdResponse {
installationId: string;
}
export interface LicenseKeyResponse {
status: 'ready' | 'pending';
license_key?: string;
email?: string;
plan?: string;
}
export interface LicenseInfo {
licenseType: 'NORMAL' | 'PRO' | 'ENTERPRISE';
enabled: boolean;
maxUsers: number;
hasKey: boolean;
licenseKey?: string; // The actual license key (for upgrades)
}
export interface LicenseSaveResponse {
success: boolean;
licenseType?: string;
message?: string;
error?: string;
}
// Currency symbol mapping
const getCurrencySymbol = (currency: string): string => {
const currencySymbols: { [key: string]: string } = {
'gbp': '£',
'usd': '$',
'eur': '€',
'cny': '¥',
'inr': '₹',
'brl': 'R$',
'idr': 'Rp'
};
return currencySymbols[currency.toLowerCase()] || currency.toUpperCase();
};
// Self-hosted plan lookup keys
const SELF_HOSTED_LOOKUP_KEYS = [
'selfhosted:server:monthly',
'selfhosted:server:yearly',
'selfhosted:enterpriseseat:monthly',
'selfhosted:enterpriseseat:yearly',
];
const licenseService = {
/**
* Get available plans with pricing for the specified currency
*/
async getPlans(currency: string = 'gbp'): Promise<PlansResponse> {
try {
// Check if Supabase is configured
if (!isSupabaseConfigured || !supabase) {
throw new Error('Supabase is not configured. Please use static plans instead.');
}
// Fetch all self-hosted prices from Stripe
const { data, error } = await supabase.functions.invoke<{
prices: Record<string, {
unit_amount: number;
currency: string;
lookup_key: string;
}>;
missing: string[];
}>('stripe-price-lookup', {
body: {
lookup_keys: SELF_HOSTED_LOOKUP_KEYS,
currency
},
});
if (error) {
throw new Error(`Failed to fetch plans: ${error.message}`);
}
if (!data || !data.prices) {
throw new Error('No pricing data returned');
}
// Log missing prices for debugging
if (data.missing && data.missing.length > 0) {
console.warn('Missing Stripe prices for lookup keys:', data.missing, 'in currency:', currency);
}
// Build price map for easy access
const priceMap = new Map<string, { unit_amount: number; currency: string }>();
for (const [lookupKey, priceData] of Object.entries(data.prices)) {
priceMap.set(lookupKey, {
unit_amount: priceData.unit_amount,
currency: priceData.currency
});
}
const currencySymbol = getCurrencySymbol(currency);
// Helper to get price info
const getPriceInfo = (lookupKey: string, fallback: number = 0) => {
const priceData = priceMap.get(lookupKey);
return priceData ? priceData.unit_amount / 100 : fallback;
};
// Build plan tiers
const plans: PlanTier[] = [
{
id: 'selfhosted:server:monthly',
lookupKey: 'selfhosted:server:monthly',
name: 'Server - Monthly',
price: getPriceInfo('selfhosted:server:monthly'),
currency: currencySymbol,
period: '/month',
popular: false,
features: PLAN_FEATURES.SERVER,
highlights: PLAN_HIGHLIGHTS.SERVER_MONTHLY
},
{
id: 'selfhosted:server:yearly',
lookupKey: 'selfhosted:server:yearly',
name: 'Server - Yearly',
price: getPriceInfo('selfhosted:server:yearly'),
currency: currencySymbol,
period: '/year',
popular: true,
features: PLAN_FEATURES.SERVER,
highlights: PLAN_HIGHLIGHTS.SERVER_YEARLY
},
{
id: 'selfhosted:enterprise:monthly',
lookupKey: 'selfhosted:server:monthly',
name: 'Enterprise - Monthly',
price: getPriceInfo('selfhosted:server:monthly'),
seatPrice: getPriceInfo('selfhosted:enterpriseseat:monthly'),
currency: currencySymbol,
period: '/month',
popular: false,
requiresSeats: true,
features: PLAN_FEATURES.ENTERPRISE,
highlights: PLAN_HIGHLIGHTS.ENTERPRISE_MONTHLY
},
{
id: 'selfhosted:enterprise:yearly',
lookupKey: 'selfhosted:server:yearly',
name: 'Enterprise - Yearly',
price: getPriceInfo('selfhosted:server:yearly'),
seatPrice: getPriceInfo('selfhosted:enterpriseseat:yearly'),
currency: currencySymbol,
period: '/year',
popular: false,
requiresSeats: true,
features: PLAN_FEATURES.ENTERPRISE,
highlights: PLAN_HIGHLIGHTS.ENTERPRISE_YEARLY
},
];
// Filter out plans with missing prices (price === 0 means Stripe price not found)
const validPlans = plans.filter(plan => plan.price > 0);
if (validPlans.length < plans.length) {
const missingPlans = plans.filter(plan => plan.price === 0).map(p => p.id);
console.warn('Filtered out plans with missing prices:', missingPlans);
}
// Add Free plan (static definition)
const freePlan: PlanTier = {
id: 'free',
lookupKey: 'free',
name: 'Free',
price: 0,
currency: currencySymbol,
period: '',
popular: false,
features: PLAN_FEATURES.FREE,
highlights: PLAN_HIGHLIGHTS.FREE
};
const allPlans = [freePlan, ...validPlans];
return {
plans: allPlans
};
} catch (error) {
console.error('Error fetching plans:', error);
throw error;
}
},
/**
* Group plans by tier for display (Free, Server, Enterprise)
*/
groupPlansByTier(plans: PlanTier[]): PlanTierGroup[] {
const groups: PlanTierGroup[] = [];
// Free tier
const freePlan = plans.find(p => p.id === 'free');
if (freePlan) {
groups.push({
tier: 'free',
name: 'Free',
monthly: freePlan,
yearly: null,
features: freePlan.features,
highlights: freePlan.highlights,
popular: false,
});
}
// Server tier
const serverMonthly = plans.find(p => p.lookupKey === 'selfhosted:server:monthly');
const serverYearly = plans.find(p => p.lookupKey === 'selfhosted:server:yearly');
if (serverMonthly || serverYearly) {
groups.push({
tier: 'server',
name: 'Server',
monthly: serverMonthly || null,
yearly: serverYearly || null,
features: (serverMonthly || serverYearly)!.features,
highlights: (serverMonthly || serverYearly)!.highlights,
popular: serverYearly?.popular || serverMonthly?.popular || false,
});
}
// Enterprise tier (uses server pricing + seats)
const enterpriseMonthly = plans.find(p => p.id === 'selfhosted:enterprise:monthly');
const enterpriseYearly = plans.find(p => p.id === 'selfhosted:enterprise:yearly');
if (enterpriseMonthly || enterpriseYearly) {
groups.push({
tier: 'enterprise',
name: 'Enterprise',
monthly: enterpriseMonthly || null,
yearly: enterpriseYearly || null,
features: (enterpriseMonthly || enterpriseYearly)!.features,
highlights: (enterpriseMonthly || enterpriseYearly)!.highlights,
popular: false,
});
}
return groups;
},
/**
* Create a Stripe checkout session for upgrading
*/
async createCheckoutSession(request: CheckoutSessionRequest): Promise<CheckoutSessionResponse> {
// Check if Supabase is configured
if (!isSupabaseConfigured || !supabase) {
throw new Error('Supabase is not configured. Checkout is not available.');
}
// 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,
lookup_key: request.lookup_key,
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: 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,
},
});
if (error) {
throw new Error(`Failed to create checkout session: ${error.message}`);
}
return data as CheckoutSessionResponse;
},
/**
* Create a Stripe billing portal session for managing subscription
* Uses license key for self-hosted authentication
*/
async createBillingPortalSession(returnUrl: string, licenseKey: string): Promise<BillingPortalResponse> {
// Check if Supabase is configured
if (!isSupabaseConfigured || !supabase) {
throw new Error('Supabase is not configured. Billing portal is not available.');
}
const { data, error} = await supabase.functions.invoke('manage-billing', {
body: {
return_url: returnUrl,
license_key: licenseKey,
self_hosted: true // Explicitly indicate self-hosted mode
},
});
if (error) {
throw new Error(`Failed to create billing portal session: ${error.message}`);
}
return data as BillingPortalResponse;
},
/**
* Get the installation ID from the backend (MAC-based fingerprint)
*/
async getInstallationId(): Promise<string> {
try {
const response = await apiClient.get('/api/v1/admin/installation-id');
const data: InstallationIdResponse = await response.data;
return data.installationId;
} catch (error) {
console.error('Error fetching installation ID:', error);
throw error;
}
},
/**
* Check if license key is ready for the given installation ID
*/
async checkLicenseKey(installationId: string): Promise<LicenseKeyResponse> {
// Check if Supabase is configured
if (!isSupabaseConfigured || !supabase) {
throw new Error('Supabase is not configured. License key lookup is not available.');
}
const { data, error } = await supabase.functions.invoke('get-license-key', {
body: {
installation_id: installationId,
},
});
if (error) {
throw new Error(`Failed to check license key: ${error.message}`);
}
return data as LicenseKeyResponse;
},
/**
* Save license key to backend
*/
async saveLicenseKey(licenseKey: string): Promise<LicenseSaveResponse> {
try {
const response = await apiClient.post('/api/v1/admin/license-key', {
licenseKey: licenseKey,
});
return response.data;
} catch (error) {
console.error('Error saving license key:', error);
throw error;
}
},
/**
* Get current license information from backend
*/
async getLicenseInfo(): Promise<LicenseInfo> {
try {
const response = await apiClient.get('/api/v1/admin/license-info');
return response.data;
} catch (error) {
console.error('Error fetching license info:', error);
throw error;
}
},
/**
* Resync the current license with Keygen
* Re-validates the existing license key and updates local settings
*/
async resyncLicense(): Promise<LicenseSaveResponse> {
try {
const response = await apiClient.post('/api/v1/admin/license/resync');
return response.data;
} catch (error) {
console.error('Error resyncing license:', error);
throw error;
}
},
};
/**
* 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;
@@ -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();
}