diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/AdminLicenseController.java b/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/AdminLicenseController.java new file mode 100644 index 000000000..cd782cdd1 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/AdminLicenseController.java @@ -0,0 +1,52 @@ +package stirling.software.proprietary.controller.api; + +import java.util.Map; + +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; + +import lombok.extern.slf4j.Slf4j; + +import stirling.software.common.util.GeneralUtils; + +/** + * Admin controller for license management. Provides installation ID for Stripe checkout metadata. + */ +@RestController +@Slf4j +@RequestMapping("/api/v1/admin") +@PreAuthorize("hasRole('ROLE_ADMIN')") +@Tag(name = "Admin License Management", description = "Admin-only License Management APIs") +public class AdminLicenseController { + + /** + * Get the installation ID (machine fingerprint) for this self-hosted instance. This ID is used + * as metadata in Stripe checkout to link licenses to specific installations. + * + * @return Map containing the installation ID + */ + @GetMapping("/installation-id") + @Operation( + summary = "Get installation ID", + description = + "Returns the unique installation ID (MAC-based fingerprint) for this" + + " self-hosted instance") + @PreAuthorize("hasRole('ROLE_ADMIN')") + public ResponseEntity> getInstallationId() { + try { + String installationId = GeneralUtils.generateMachineFingerprint(); + log.info("Admin requested installation ID: {}", installationId); + return ResponseEntity.ok(Map.of("installationId", installationId)); + } catch (Exception e) { + log.error("Failed to generate installation ID", e); + return ResponseEntity.internalServerError() + .body(Map.of("error", "Failed to generate installation ID")); + } + } +} diff --git a/frontend/package-lock.json b/frontend/package-lock.json index a9b4cab03..ccf1b3253 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -40,6 +40,7 @@ "@reactour/tour": "^3.8.0", "@stripe/react-stripe-js": "^4.0.2", "@stripe/stripe-js": "^7.9.0", + "@supabase/supabase-js": "^2.47.13", "@tailwindcss/postcss": "^4.1.13", "@tanstack/react-virtual": "^3.13.12", "@tauri-apps/api": "^2.5.0", @@ -3087,6 +3088,115 @@ "node": ">=12.16" } }, + "node_modules/@supabase/auth-js": { + "version": "2.81.1", + "resolved": "https://registry.npmjs.org/@supabase/auth-js/-/auth-js-2.81.1.tgz", + "integrity": "sha512-K20GgiSm9XeRLypxYHa5UCnybWc2K0ok0HLbqCej/wRxDpJxToXNOwKt0l7nO8xI1CyQ+GrNfU6bcRzvdbeopQ==", + "license": "MIT", + "dependencies": { + "tslib": "2.8.1" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@supabase/auth-js/node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/@supabase/functions-js": { + "version": "2.81.1", + "resolved": "https://registry.npmjs.org/@supabase/functions-js/-/functions-js-2.81.1.tgz", + "integrity": "sha512-sYgSO3mlgL0NvBFS3oRfCK4OgKGQwuOWJLzfPyWg0k8MSxSFSDeN/JtrDJD5GQrxskP6c58+vUzruBJQY78AqQ==", + "license": "MIT", + "dependencies": { + "tslib": "2.8.1" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@supabase/functions-js/node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/@supabase/postgrest-js": { + "version": "2.81.1", + "resolved": "https://registry.npmjs.org/@supabase/postgrest-js/-/postgrest-js-2.81.1.tgz", + "integrity": "sha512-DePpUTAPXJyBurQ4IH2e42DWoA+/Qmr5mbgY4B6ZcxVc/ZUKfTVK31BYIFBATMApWraFc8Q/Sg+yxtfJ3E0wSg==", + "license": "MIT", + "dependencies": { + "tslib": "2.8.1" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@supabase/postgrest-js/node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/@supabase/realtime-js": { + "version": "2.81.1", + "resolved": "https://registry.npmjs.org/@supabase/realtime-js/-/realtime-js-2.81.1.tgz", + "integrity": "sha512-ViQ+Kxm8BuUP/TcYmH9tViqYKGSD1LBjdqx2p5J+47RES6c+0QHedM0PPAjthMdAHWyb2LGATE9PD2++2rO/tw==", + "license": "MIT", + "dependencies": { + "@types/phoenix": "^1.6.6", + "@types/ws": "^8.18.1", + "tslib": "2.8.1", + "ws": "^8.18.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@supabase/realtime-js/node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/@supabase/storage-js": { + "version": "2.81.1", + "resolved": "https://registry.npmjs.org/@supabase/storage-js/-/storage-js-2.81.1.tgz", + "integrity": "sha512-UNmYtjnZnhouqnbEMC1D5YJot7y0rIaZx7FG2Fv8S3hhNjcGVvO+h9We/tggi273BFkiahQPS/uRsapo1cSapw==", + "license": "MIT", + "dependencies": { + "tslib": "2.8.1" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@supabase/storage-js/node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/@supabase/supabase-js": { + "version": "2.81.1", + "resolved": "https://registry.npmjs.org/@supabase/supabase-js/-/supabase-js-2.81.1.tgz", + "integrity": "sha512-KSdY7xb2L0DlLmlYzIOghdw/na4gsMcqJ8u4sD6tOQJr+x3hLujU9s4R8N3ob84/1bkvpvlU5PYKa1ae+OICnw==", + "license": "MIT", + "dependencies": { + "@supabase/auth-js": "2.81.1", + "@supabase/functions-js": "2.81.1", + "@supabase/postgrest-js": "2.81.1", + "@supabase/realtime-js": "2.81.1", + "@supabase/storage-js": "2.81.1" + }, + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/@sveltejs/acorn-typescript": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.6.tgz", @@ -4160,7 +4270,6 @@ "version": "24.9.1", "resolved": "https://registry.npmjs.org/@types/node/-/node-24.9.1.tgz", "integrity": "sha512-QoiaXANRkSXK6p0Duvt56W208du4P9Uye9hWLWgGMDTEoKPhuenzNcC4vGUmrNkiOKTlIrBoyNQYNpSwfEZXSg==", - "dev": true, "license": "MIT", "dependencies": { "undici-types": "~7.16.0" @@ -4172,6 +4281,12 @@ "integrity": "sha512-dISoDXWWQwUquiKsyZ4Ng+HX2KsPL7LyHKHQwgGFEA3IaKac4Obd+h2a/a6waisAoepJlBcx9paWqjA8/HVjCw==", "license": "MIT" }, + "node_modules/@types/phoenix": { + "version": "1.6.6", + "resolved": "https://registry.npmjs.org/@types/phoenix/-/phoenix-1.6.6.tgz", + "integrity": "sha512-PIzZZlEppgrpoT2QgbnDU+MMzuR6BbCjllj0bM70lWoejMeNJAxCchxnv7J3XFkI8MpygtRpzXrIlmWUBclP5A==", + "license": "MIT" + }, "node_modules/@types/prop-types": { "version": "15.7.15", "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", @@ -4206,6 +4321,15 @@ "@types/react": "*" } }, + "node_modules/@types/ws": { + "version": "8.18.1", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", + "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/yauzl": { "version": "2.10.3", "resolved": "https://registry.npmjs.org/@types/yauzl/-/yauzl-2.10.3.tgz", @@ -13919,7 +14043,6 @@ "version": "7.16.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", - "dev": true, "license": "MIT" }, "node_modules/universalify": { @@ -14790,7 +14913,6 @@ "version": "8.18.3", "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz", "integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==", - "dev": true, "license": "MIT", "engines": { "node": ">=10.0.0" diff --git a/frontend/package.json b/frontend/package.json index 8836fae58..18100ba8b 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -35,6 +35,7 @@ "@mantine/hooks": "^8.3.1", "@stripe/react-stripe-js": "^4.0.2", "@stripe/stripe-js": "^7.9.0", + "@supabase/supabase-js": "^2.47.13", "@mui/icons-material": "^7.3.2", "@mui/material": "^7.3.2", "@reactour/tour": "^3.8.0", diff --git a/frontend/src/core/components/shared/StripeCheckout.tsx b/frontend/src/core/components/shared/StripeCheckout.tsx index ca1870b79..ab54a1905 100644 --- a/frontend/src/core/components/shared/StripeCheckout.tsx +++ b/frontend/src/core/components/shared/StripeCheckout.tsx @@ -1,9 +1,9 @@ -import React, { useState, useEffect } from 'react'; -import { Modal, Button, Text, Alert, Loader, Stack } from '@mantine/core'; +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 from '@app/services/licenseService'; +import licenseService, { PlanTierGroup } from '@app/services/licenseService'; import { Z_INDEX_OVER_CONFIG_MODAL } from '@app/styles/zIndex'; // Initialize Stripe - this should come from environment variables @@ -12,10 +12,8 @@ const stripePromise = loadStripe(import.meta.env.VITE_STRIPE_PUBLISHABLE_KEY || interface StripeCheckoutProps { opened: boolean; onClose: () => void; - planId: string; - planName: string; - planPrice: number; - currency: string; + planGroup: PlanTierGroup; + email: string; onSuccess?: (sessionId: string) => void; onError?: (error: string) => void; } @@ -30,23 +28,48 @@ type CheckoutState = { const StripeCheckout: React.FC = ({ opened, onClose, - planId, - planName, - planPrice, - currency, + planGroup, + email, onSuccess, onError, }) => { const { t } = useTranslation(); const [state, setState] = useState({ 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(null); + const [licenseKey, setLicenseKey] = useState(null); + const [pollingStatus, setPollingStatus] = useState<'idle' | 'polling' | 'ready' | 'timeout'>('idle'); + + // 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); + } + const response = await licenseService.createCheckoutSession({ - planId, - currency, + lookup_key: selectedPlan.lookupKey, + email, + installation_id: fetchedInstallationId, + requires_seats: selectedPlan.requiresSeats, successUrl: `${window.location.origin}/settings/adminPlan?session_id={CHECKOUT_SESSION_ID}`, cancelUrl: `${window.location.origin}/settings/adminPlan`, }); @@ -67,51 +90,167 @@ const StripeCheckout: React.FC = ({ } }; + const pollForLicenseKey = useCallback(async (installId: string) => { + const maxAttempts = 15; // 30 seconds (15 × 2s) + let attempts = 0; + + setPollingStatus('polling'); + + const poll = async (): Promise => { + try { + const response = await licenseService.checkLicenseKey(installId); + + if (response.status === 'ready' && response.license_key) { + setLicenseKey(response.license_key); + setPollingStatus('ready'); + return; + } + + attempts++; + if (attempts >= maxAttempts) { + setPollingStatus('timeout'); + return; + } + + // Continue polling + await new Promise(resolve => setTimeout(resolve, 2000)); + return poll(); + + } catch (error) { + console.error('License polling error:', error); + attempts++; + if (attempts >= maxAttempts) { + setPollingStatus('timeout'); + return; + } + await new Promise(resolve => setTimeout(resolve, 2000)); + return poll(); + } + }; + + await poll(); + }, []); + const handlePaymentComplete = () => { - setState({ status: 'success' }); - onSuccess?.(state.sessionId || ''); + // Preserve state when changing status + setState(prev => ({ ...prev, status: 'success' })); + + // Start polling BEFORE notifying parent (so modal stays open) + 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 = () => { setState({ status: 'idle' }); + // Reset to default period on close + setSelectedPeriod(planGroup.yearly ? 'yearly' : 'monthly'); onClose(); }; - // Initialize checkout when modal opens + const handlePeriodChange = (value: string) => { + setSelectedPeriod(value as 'monthly' | 'yearly'); + // Reset state to trigger checkout reload + setState({ status: 'idle' }); + }; + + // 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; + } + if (opened && state.status === 'idle') { createCheckoutSession(); } else if (!opened) { setState({ status: 'idle' }); } - }, [opened]); + }, [opened, selectedPeriod, state.status]); const renderContent = () => { switch (state.status) { case 'loading': return ( -
+ {t('payment.preparing', 'Preparing your checkout...')} -
+ ); case 'ready': - if (!state.clientSecret) return null; + 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 ( - - - + + {/* Left: Period Selector - only show if both periods available */} + {periodData.length > 1 && ( + + + + {t('payment.billingPeriod', 'Billing Period')} + + + {selectedPlan.requiresSeats && selectedPlan.seatPrice && ( + + {t('payment.enterpriseNote', 'Seats can be adjusted in checkout (1-1000).')} + + )} + + + )} + + {/* Right: Stripe Checkout */} + 1 ? 9 : 12}> + + + + + ); case 'success': @@ -121,12 +260,72 @@ const StripeCheckout: React.FC = ({ {t( 'payment.successMessage', - 'Your subscription has been activated successfully. You will receive a confirmation email shortly.' + 'Your subscription has been activated successfully.' )} - - {t('payment.autoClose', 'This window will close automatically...')} - + + {/* Installation ID Display */} + {installationId && ( + + + + {t('payment.installationId', 'Installation ID')} + + {installationId} + + + )} + + {/* License Key Polling Status */} + {pollingStatus === 'polling' && ( + + + + {t('payment.generatingLicense', 'Generating your license key...')} + + + )} + + {pollingStatus === 'ready' && licenseKey && ( + + + + {t('payment.licenseKey', 'Your License Key')} + + {licenseKey} + + + {t( + 'payment.licenseInstructions', + 'Enter this key in Settings → Admin Plan → License Key section' + )} + + + + )} + + {pollingStatus === 'timeout' && ( + + + {t( + 'payment.licenseDelayedMessage', + 'Your license key is being generated. Please check your email shortly or contact support.' + )} + + + )} + + {pollingStatus === 'ready' && ( + + {t('payment.canCloseWindow', 'You can now close this window.')} + + )} ); @@ -153,22 +352,24 @@ const StripeCheckout: React.FC = ({ opened={opened} onClose={handleClose} title={ -
- - {t('payment.upgradeTitle', 'Upgrade to {{planName}}', { planName })} - - - {currency} - {planPrice}/{t('plan.period.month', 'month')} - -
+ + {t('payment.upgradeTitle', 'Upgrade to {{planName}}', { planName: planGroup.name })} + } - size="xl" + size="90%" centered - withCloseButton={state.status !== 'ready'} - closeOnEscape={state.status !== 'ready'} - closeOnClickOutside={state.status !== 'ready'} + withCloseButton={true} + closeOnEscape={true} + closeOnClickOutside={false} zIndex={Z_INDEX_OVER_CONFIG_MODAL} + styles={{ + body: { + minHeight: '85vh', + }, + content: { + maxHeight: '95vh', + }, + }} > {renderContent()} diff --git a/frontend/src/core/components/shared/config/configSections/AdminPlanSection.tsx b/frontend/src/core/components/shared/config/configSections/AdminPlanSection.tsx index 6f44fac6d..cbb48e6d4 100644 --- a/frontend/src/core/components/shared/config/configSections/AdminPlanSection.tsx +++ b/frontend/src/core/components/shared/config/configSections/AdminPlanSection.tsx @@ -2,7 +2,7 @@ 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 { PlanTier } from '@app/services/licenseService'; +import { PlanTierGroup } from '@app/services/licenseService'; import StripeCheckout from '@app/components/shared/StripeCheckout'; import AvailablePlansSection from './plan/AvailablePlansSection'; import ActivePlanSection from './plan/ActivePlanSection'; @@ -25,11 +25,12 @@ const AdminPlanSection: React.FC = () => { const { t } = useTranslation(); const { config } = useAppConfig(); const [checkoutOpen, setCheckoutOpen] = useState(false); - const [selectedPlan, setSelectedPlan] = useState(null); + const [selectedPlanGroup, setSelectedPlanGroup] = useState(null); const [currency, setCurrency] = useState('gbp'); const [useStaticVersion, setUseStaticVersion] = useState(false); 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 @@ -105,35 +106,43 @@ const AdminPlanSection: React.FC = () => { ]; const handleUpgradeClick = useCallback( - (plan: PlanTier) => { - if (plan.isContactOnly) { - // Open contact form or redirect to contact page - window.open('https://www.stirling.com/contact', '_blank'); + (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; } - if (!currentSubscription || plan.id !== currentSubscription.plan.id) { - setSelectedPlan(plan); - setCheckoutOpen(true); + // 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); }, - [currentSubscription] + [email, t] ); const handlePaymentSuccess = useCallback( (sessionId: string) => { console.log('Payment successful, session:', sessionId); - // Refetch plans to update current subscription - refetch(); - - // Close modal after brief delay to show success message - setTimeout(() => { - setCheckoutOpen(false); - setSelectedPlan(null); - }, 2000); + // 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 }, - [refetch] + [] ); const handlePaymentError = useCallback((error: string) => { @@ -143,8 +152,11 @@ const AdminPlanSection: React.FC = () => { const handleCheckoutClose = useCallback(() => { setCheckoutOpen(false); - setSelectedPlan(null); - }, []); + 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) { @@ -165,7 +177,7 @@ const AdminPlanSection: React.FC = () => { return ; } - if (!plans || !currentSubscription) { + if (!plans || plans.length === 0) { return ( Plans data is not available at the moment. @@ -175,42 +187,57 @@ const AdminPlanSection: React.FC = () => { return (
- {/* Currency Selector */} -
- + {/* Customer Information Section */} + + - {t('plan.currency', 'Currency')} + {t('admin.plan.customerInfo', 'Customer Information')} - setCurrency(value || 'gbp')} + data={currencyOptions} + searchable + clearable={false} + w={300} + /> + + + - - - + {currentSubscription && ( + <> + + + + )} {/* Stripe Checkout Modal */} - {selectedPlan && ( + {selectedPlanGroup && ( diff --git a/frontend/src/core/components/shared/config/configSections/plan/AvailablePlansSection.tsx b/frontend/src/core/components/shared/config/configSections/plan/AvailablePlansSection.tsx index 470dd4c94..1b5cdea90 100644 --- a/frontend/src/core/components/shared/config/configSections/plan/AvailablePlansSection.tsx +++ b/frontend/src/core/components/shared/config/configSections/plan/AvailablePlansSection.tsx @@ -1,12 +1,12 @@ -import React, { useState } from 'react'; +import React, { useState, useMemo } from 'react'; import { Button, Card, Badge, Text, Collapse } from '@mantine/core'; import { useTranslation } from 'react-i18next'; -import { PlanTier } from '@app/services/licenseService'; +import licenseService, { PlanTier, PlanTierGroup } from '@app/services/licenseService'; import PlanCard from './PlanCard'; interface AvailablePlansSectionProps { plans: PlanTier[]; - currentPlanId: string; + currentPlanId?: string; onUpgradeClick: (plan: PlanTier) => void; } @@ -18,6 +18,20 @@ const AvailablePlansSection: React.FC = ({ const { t } = useTranslation(); const [showComparison, setShowComparison] = useState(false); + // Group plans by tier (Free, Server, Enterprise) + const groupedPlans = useMemo(() => { + return licenseService.groupPlansByTier(plans); + }, [plans]); + + // Determine if the current tier matches + const isCurrentTier = (tierGroup: PlanTierGroup): boolean => { + if (!currentPlanId) return false; + return ( + tierGroup.monthly?.id === currentPlanId || + tierGroup.yearly?.id === currentPlanId + ); + }; + return (

@@ -41,11 +55,11 @@ const AvailablePlansSection: React.FC = ({ marginBottom: '1rem', }} > - {plans.map((plan) => ( + {groupedPlans.map((group) => ( ))} @@ -66,30 +80,32 @@ const AvailablePlansSection: React.FC = ({
- +
- - + - {plans.map((plan) => ( + {groupedPlans.map((group) => ( - {plans[0].features.map((_, featureIndex) => ( + {groupedPlans[0]?.features.map((_, featureIndex) => ( - - {plans.map((plan) => ( - + {groupedPlans.map((group) => ( + ))} diff --git a/frontend/src/core/components/shared/config/configSections/plan/PlanCard.tsx b/frontend/src/core/components/shared/config/configSections/plan/PlanCard.tsx index 77a7902a5..26acde500 100644 --- a/frontend/src/core/components/shared/config/configSections/plan/PlanCard.tsx +++ b/frontend/src/core/components/shared/config/configSections/plan/PlanCard.tsx @@ -1,20 +1,83 @@ import React from 'react'; -import { Button, Card, Badge, Text, Group, Stack } from '@mantine/core'; +import { Button, Card, Badge, Text, Group, Stack, Divider } from '@mantine/core'; import { useTranslation } from 'react-i18next'; -import { PlanTier } from '@app/services/licenseService'; +import { PlanTierGroup } from '@app/services/licenseService'; interface PlanCardProps { - plan: PlanTier; - isCurrentPlan: boolean; - onUpgradeClick: (plan: PlanTier) => void; + planGroup: PlanTierGroup; + isCurrentTier: boolean; + onUpgradeClick: (planGroup: PlanTierGroup) => void; } -const PlanCard: React.FC = ({ plan, isCurrentPlan, onUpgradeClick }) => { +const PlanCard: React.FC = ({ planGroup, isCurrentTier, onUpgradeClick }) => { const { t } = useTranslation(); + // Render Free plan + if (planGroup.tier === 'free') { + return ( + + +
+ + {planGroup.name} + + + £0 + + + {t('plan.free.forever', 'Forever free')} + +
+ + + {planGroup.highlights.map((highlight, index) => ( + + • {highlight} + + ))} + + +
+ + + + + ); + } + + // 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 || '£'; + let isYearlyPrice = false; + + if (yearly) { + displayPrice = Math.round(yearly.price / 12); + displaySeatPrice = yearly.seatPrice ? Math.round(yearly.seatPrice / 12) : undefined; + displayCurrency = yearly.currency; + isYearlyPrice = true; + } + return ( = ({ plan, isCurrentPlan, onUpgradeClick position: 'relative', display: 'flex', flexDirection: 'column', + minHeight: '400px', }} > - {plan.popular && ( + {planGroup.popular && ( {t('plan.popular', 'Popular')} )} + {/* Tier Name */}
- - {plan.name} + + {planGroup.name} - - - {plan.isContactOnly - ? t('plan.customPricing', 'Custom') - : `${plan.currency}${plan.price}`} - - {!plan.isContactOnly && ( - - {plan.period} - - )} -
+ {/* "From" Pricing */} +
+ + {t('plan.from', 'From')} + + + {isEnterprise && displaySeatPrice !== undefined ? ( +
+ + + {displayCurrency}{displayPrice} + + + {t('plan.perMonth', '/month')} + + + + + {displayCurrency}{displaySeatPrice}/seat/month + +
+ ) : ( + + + {displayCurrency}{displayPrice} + + + {t('plan.perMonth', '/month')} + + + )} + + +
+ + + + {/* Highlights */} - {plan.highlights.map((highlight, index) => ( + {planGroup.highlights.map((highlight, index) => ( • {highlight} @@ -63,16 +153,17 @@ const PlanCard: React.FC = ({ plan, isCurrentPlan, onUpgradeClick
+ {/* Single Upgrade Button */} diff --git a/frontend/src/core/services/licenseService.ts b/frontend/src/core/services/licenseService.ts index c8e132222..070d09f83 100644 --- a/frontend/src/core/services/licenseService.ts +++ b/frontend/src/core/services/licenseService.ts @@ -1,4 +1,5 @@ -import apiClient from '@app/services/apiClient'; +import apiClient from './apiClient'; +import { supabase } from './supabaseClient'; export interface PlanFeature { name: string; @@ -15,6 +16,19 @@ export interface PlanTier { features: PlanFeature[]; highlights: 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: string[]; + popular?: boolean; } export interface SubscriptionInfo { @@ -28,14 +42,17 @@ export interface SubscriptionInfo { export interface PlansResponse { plans: PlanTier[]; - currentSubscription: SubscriptionInfo; + currentSubscription: SubscriptionInfo | null; } export interface CheckoutSessionRequest { - planId: string; - currency: string; - successUrl: string; - cancelUrl: string; + 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) + 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 { @@ -47,44 +64,370 @@ export interface BillingPortalResponse { url: string; } +export interface InstallationIdResponse { + installationId: string; +} + +export interface LicenseKeyResponse { + status: 'ready' | 'pending'; + license_key?: string; + email?: string; + plan?: 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 { - const response = await apiClient.get(`/api/v1/license/plans`, { - params: { currency }, - }); - return response.data; + try { + // Fetch all self-hosted prices from Stripe + const { data, error } = await supabase.functions.invoke<{ + prices: Record; + 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(); + 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: [ + { name: 'Self-hosted deployment', included: true }, + { name: 'All PDF operations', included: true }, + { name: 'Unlimited users', included: true }, + { name: 'Community support', included: true }, + { name: 'Regular updates', included: true }, + { name: 'Priority support', included: false }, + { name: 'Custom integrations', included: false }, + ], + highlights: [ + 'Self-hosted on your infrastructure', + 'All features included', + 'Cancel anytime' + ] + }, + { + id: 'selfhosted:server:yearly', + lookupKey: 'selfhosted:server:yearly', + name: 'Server - Yearly', + price: getPriceInfo('selfhosted:server:yearly'), + currency: currencySymbol, + period: '/year', + popular: true, + features: [ + { name: 'Self-hosted deployment', included: true }, + { name: 'All PDF operations', included: true }, + { name: 'Unlimited users', included: true }, + { name: 'Community support', included: true }, + { name: 'Regular updates', included: true }, + { name: 'Priority support', included: false }, + { name: 'Custom integrations', included: false }, + ], + highlights: [ + 'Self-hosted on your infrastructure', + 'All features included', + 'Save with annual billing' + ] + }, + { + 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: [ + { name: 'Self-hosted deployment', included: true }, + { name: 'All PDF operations', included: true }, + { name: 'Per-seat licensing', included: true }, + { name: 'Priority support', included: true }, + { name: 'SLA guarantee', included: true }, + { name: 'Custom integrations', included: true }, + { name: 'Dedicated account manager', included: true }, + ], + highlights: [ + 'Enterprise-grade support', + 'Custom integrations available', + 'SLA guarantee included' + ] + }, + { + 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: [ + { name: 'Self-hosted deployment', included: true }, + { name: 'All PDF operations', included: true }, + { name: 'Per-seat licensing', included: true }, + { name: 'Priority support', included: true }, + { name: 'SLA guarantee', included: true }, + { name: 'Custom integrations', included: true }, + { name: 'Dedicated account manager', included: true }, + ], + highlights: [ + 'Enterprise-grade support', + 'Custom integrations available', + 'Save with annual billing' + ] + }, + ]; + + // 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: [ + { name: 'Self-hosted deployment', included: true }, + { name: 'All PDF operations', included: true }, + { name: 'Up to 5 users', included: true }, + { name: 'Community support', included: true }, + { name: 'Regular updates', included: true }, + { name: 'Priority support', included: false }, + { name: 'SLA guarantee', included: false }, + { name: 'Custom integrations', included: false }, + { name: 'Dedicated account manager', included: false }, + ], + highlights: [ + 'Up to 5 users', + 'Self-hosted', + 'All basic features' + ] + }; + + const allPlans = [freePlan, ...validPlans]; + + return { + plans: allPlans, + currentSubscription: null // Will be implemented later + }; + } catch (error) { + console.error('Error fetching plans:', error); + throw error; + } }, /** * Get current subscription details + * TODO: Implement with Supabase edge function when available */ - async getCurrentSubscription(): Promise { - const response = await apiClient.get('/api/v1/license/subscription'); - return response.data; + async getCurrentSubscription(): Promise { + // Placeholder - will be implemented later + return null; + }, + + /** + * 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 { - const response = await apiClient.post( - '/api/v1/license/checkout', - request - ); - return response.data; + const { data, error } = await supabase.functions.invoke('create-checkout', { + body: { + self_hosted: true, + lookup_key: request.lookup_key, + email: request.email, + installation_id: request.installation_id, + requires_seats: request.requires_seats, + seat_count: request.seat_count || 1, + callback_base_url: window.location.origin, + }, + }); + + 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 */ - async createBillingPortalSession(returnUrl: string): Promise { - const response = await apiClient.post('/api/v1/license/billing-portal', { - returnUrl, + async createBillingPortalSession(email: string, returnUrl: string): Promise { + const { data, error} = await supabase.functions.invoke('manage-billing', { + body: { + email, + returnUrl + }, }); - return response.data; + + 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 { + 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 { + 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; }, }; diff --git a/frontend/src/core/services/supabaseClient.ts b/frontend/src/core/services/supabaseClient.ts new file mode 100644 index 000000000..e55948af4 --- /dev/null +++ b/frontend/src/core/services/supabaseClient.ts @@ -0,0 +1,10 @@ +import { createClient } from '@supabase/supabase-js'; + +const supabaseUrl = import.meta.env.VITE_SUPABASE_URL; +const supabaseAnonKey = import.meta.env.VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY; + +if (!supabaseUrl || !supabaseAnonKey) { + throw new Error('Missing Supabase environment variables'); +} + +export const supabase = createClient(supabaseUrl, supabaseAnonKey); diff --git a/frontend/vite-env.d.ts b/frontend/vite-env.d.ts index ca36e9027..f483e59d4 100644 --- a/frontend/vite-env.d.ts +++ b/frontend/vite-env.d.ts @@ -3,6 +3,8 @@ interface ImportMetaEnv { readonly VITE_PUBLIC_POSTHOG_KEY: string; readonly VITE_PUBLIC_POSTHOG_HOST: string; + readonly VITE_SUPABASE_URL: string; + readonly VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY: string; } interface ImportMeta {
+
{t('plan.feature.title', 'Feature')} - {plan.name} - {plan.popular && ( + {group.name} + {group.popular && ( {t('plan.popular', 'Popular')} @@ -100,20 +116,24 @@ const AvailablePlansSection: React.FC = ({
{plans[0].features[featureIndex].name} - {plan.features[featureIndex].included ? ( - + + {groupedPlans[0].features[featureIndex].name} + + {group.features[featureIndex]?.included ? ( + ) : ( - - + + − + )}