From 126fc0923e24c441c3e5fddce7f07d8bfe72600e Mon Sep 17 00:00:00 2001 From: Anthony Stirling <77850077+Frooodle@users.noreply.github.com> Date: Thu, 30 Oct 2025 13:30:39 +0000 Subject: [PATCH] billingInit --- frontend/package-lock.json | 25 ++ frontend/package.json | 2 + .../components/shared/ManageBillingButton.tsx | 37 +++ .../core/components/shared/StripeCheckout.tsx | 178 ++++++++++++ .../configSections/AdminPlanSection.tsx | 180 ++++++++++++ .../configSections/plan/ActivePlanSection.tsx | 89 ++++++ .../plan/AvailablePlansSection.tsx | 131 +++++++++ .../config/configSections/plan/PlanCard.tsx | 83 ++++++ .../configSections/plan/StaticPlanSection.tsx | 262 ++++++++++++++++++ frontend/src/core/hooks/usePlans.ts | 49 ++++ frontend/src/core/services/licenseService.ts | 91 ++++++ 11 files changed, 1127 insertions(+) create mode 100644 frontend/src/core/components/shared/ManageBillingButton.tsx create mode 100644 frontend/src/core/components/shared/StripeCheckout.tsx create mode 100644 frontend/src/core/components/shared/config/configSections/AdminPlanSection.tsx create mode 100644 frontend/src/core/components/shared/config/configSections/plan/ActivePlanSection.tsx create mode 100644 frontend/src/core/components/shared/config/configSections/plan/AvailablePlansSection.tsx create mode 100644 frontend/src/core/components/shared/config/configSections/plan/PlanCard.tsx create mode 100644 frontend/src/core/components/shared/config/configSections/plan/StaticPlanSection.tsx create mode 100644 frontend/src/core/hooks/usePlans.ts create mode 100644 frontend/src/core/services/licenseService.ts diff --git a/frontend/package-lock.json b/frontend/package-lock.json index c884272c4..b9848e94d 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -38,6 +38,8 @@ "@mui/icons-material": "^7.3.2", "@mui/material": "^7.3.2", "@reactour/tour": "^3.8.0", + "@stripe/react-stripe-js": "^4.0.2", + "@stripe/stripe-js": "^7.9.0", "@tailwindcss/postcss": "^4.1.13", "@tanstack/react-virtual": "^3.13.12", "autoprefixer": "^10.4.21", @@ -3045,6 +3047,29 @@ "url": "https://github.com/sindresorhus/is?sponsor=1" } }, + "node_modules/@stripe/react-stripe-js": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@stripe/react-stripe-js/-/react-stripe-js-4.0.2.tgz", + "integrity": "sha512-l2wau+8/LOlHl+Sz8wQ1oDuLJvyw51nQCsu6/ljT6smqzTszcMHifjAJoXlnMfcou3+jK/kQyVe04u/ufyTXgg==", + "license": "MIT", + "dependencies": { + "prop-types": "^15.7.2" + }, + "peerDependencies": { + "@stripe/stripe-js": ">=1.44.1 <8.0.0", + "react": ">=16.8.0 <20.0.0", + "react-dom": ">=16.8.0 <20.0.0" + } + }, + "node_modules/@stripe/stripe-js": { + "version": "7.9.0", + "resolved": "https://registry.npmjs.org/@stripe/stripe-js/-/stripe-js-7.9.0.tgz", + "integrity": "sha512-ggs5k+/0FUJcIgNY08aZTqpBTtbExkJMYMLSMwyucrhtWexVOEY1KJmhBsxf+E/Q15f5rbwBpj+t0t2AW2oCsQ==", + "license": "MIT", + "engines": { + "node": ">=12.16" + } + }, "node_modules/@swc/core": { "version": "1.13.5", "resolved": "https://registry.npmjs.org/@swc/core/-/core-1.13.5.tgz", diff --git a/frontend/package.json b/frontend/package.json index 892e48569..0b9dfb976 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -31,6 +31,8 @@ "@mantine/dates": "^8.3.1", "@mantine/dropzone": "^8.3.1", "@mantine/hooks": "^8.3.1", + "@stripe/react-stripe-js": "^4.0.2", + "@stripe/stripe-js": "^7.9.0", "@mui/icons-material": "^7.3.2", "@mui/material": "^7.3.2", "@reactour/tour": "^3.8.0", diff --git a/frontend/src/core/components/shared/ManageBillingButton.tsx b/frontend/src/core/components/shared/ManageBillingButton.tsx new file mode 100644 index 000000000..3cbc07f4c --- /dev/null +++ b/frontend/src/core/components/shared/ManageBillingButton.tsx @@ -0,0 +1,37 @@ +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 = ({ + returnUrl = window.location.href, +}) => { + const { t } = useTranslation(); + const [loading, setLoading] = useState(false); + + const handleClick = async () => { + try { + setLoading(true); + const response = await licenseService.createBillingPortalSession(returnUrl); + window.location.href = response.url; + } catch (error) { + console.error('Failed to open billing portal:', error); + alert({ + alertType: 'error', + title: t('billing.portal.error', 'Failed to open billing portal'), + }); + setLoading(false); + } + }; + + return ( + + ); +}; diff --git a/frontend/src/core/components/shared/StripeCheckout.tsx b/frontend/src/core/components/shared/StripeCheckout.tsx new file mode 100644 index 000000000..ca1870b79 --- /dev/null +++ b/frontend/src/core/components/shared/StripeCheckout.tsx @@ -0,0 +1,178 @@ +import React, { useState, useEffect } from 'react'; +import { Modal, Button, Text, Alert, Loader, Stack } 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 { Z_INDEX_OVER_CONFIG_MODAL } from '@app/styles/zIndex'; + +// Initialize Stripe - this should come from environment variables +const stripePromise = loadStripe(import.meta.env.VITE_STRIPE_PUBLISHABLE_KEY || ''); + +interface StripeCheckoutProps { + opened: boolean; + onClose: () => void; + planId: string; + planName: string; + planPrice: number; + currency: string; + onSuccess?: (sessionId: string) => void; + onError?: (error: string) => void; +} + +type CheckoutState = { + status: 'idle' | 'loading' | 'ready' | 'success' | 'error'; + clientSecret?: string; + error?: string; + sessionId?: string; +}; + +const StripeCheckout: React.FC = ({ + opened, + onClose, + planId, + planName, + planPrice, + currency, + onSuccess, + onError, +}) => { + const { t } = useTranslation(); + const [state, setState] = useState({ status: 'idle' }); + + const createCheckoutSession = async () => { + try { + setState({ status: 'loading' }); + + const response = await licenseService.createCheckoutSession({ + planId, + currency, + successUrl: `${window.location.origin}/settings/adminPlan?session_id={CHECKOUT_SESSION_ID}`, + cancelUrl: `${window.location.origin}/settings/adminPlan`, + }); + + 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 handlePaymentComplete = () => { + setState({ status: 'success' }); + onSuccess?.(state.sessionId || ''); + }; + + const handleClose = () => { + setState({ status: 'idle' }); + onClose(); + }; + + // Initialize checkout when modal opens + useEffect(() => { + if (opened && state.status === 'idle') { + createCheckoutSession(); + } else if (!opened) { + setState({ status: 'idle' }); + } + }, [opened]); + + const renderContent = () => { + switch (state.status) { + case 'loading': + return ( +
+ + + {t('payment.preparing', 'Preparing your checkout...')} + +
+ ); + + case 'ready': + if (!state.clientSecret) return null; + + return ( + + + + ); + + case 'success': + return ( + + + + {t( + 'payment.successMessage', + 'Your subscription has been activated successfully. You will receive a confirmation email shortly.' + )} + + + {t('payment.autoClose', 'This window will close automatically...')} + + + + ); + + case 'error': + return ( + + + {state.error} + + + + ); + + default: + return null; + } + }; + + return ( + + + {t('payment.upgradeTitle', 'Upgrade to {{planName}}', { planName })} + + + {currency} + {planPrice}/{t('plan.period.month', 'month')} + + + } + size="xl" + centered + withCloseButton={state.status !== 'ready'} + closeOnEscape={state.status !== 'ready'} + closeOnClickOutside={state.status !== 'ready'} + zIndex={Z_INDEX_OVER_CONFIG_MODAL} + > + {renderContent()} + + ); +}; + +export default StripeCheckout; diff --git a/frontend/src/core/components/shared/config/configSections/AdminPlanSection.tsx b/frontend/src/core/components/shared/config/configSections/AdminPlanSection.tsx new file mode 100644 index 000000000..7f0b2c94b --- /dev/null +++ b/frontend/src/core/components/shared/config/configSections/AdminPlanSection.tsx @@ -0,0 +1,180 @@ +import React, { useState, useCallback, useEffect } from 'react'; +import { Divider, Loader, Alert, Select, Group, Text } from '@mantine/core'; +import { useTranslation } from 'react-i18next'; +import { usePlans } from '@app/hooks/usePlans'; +import { PlanTier } from '@app/services/licenseService'; +import StripeCheckout from '@app/components/shared/StripeCheckout'; +import AvailablePlansSection from './plan/AvailablePlansSection'; +import ActivePlanSection from './plan/ActivePlanSection'; +import StaticPlanSection from './plan/StaticPlanSection'; +import { userManagementService } from '@app/services/userManagementService'; +import { useAppConfig } from '@app/contexts/AppConfigContext'; + +const AdminPlanSection: React.FC = () => { + const { t } = useTranslation(); + const { config } = useAppConfig(); + const [checkoutOpen, setCheckoutOpen] = useState(false); + const [selectedPlan, setSelectedPlan] = useState(null); + const [currency, setCurrency] = useState('gbp'); + const [useStaticVersion, setUseStaticVersion] = useState(false); + const [currentLicenseInfo, setCurrentLicenseInfo] = useState(null); + const { plans, currentSubscription, loading, error, refetch } = usePlans(currency); + + // Check if we should use static version and fetch license info + useEffect(() => { + const fetchLicenseInfo = async () => { + try { + const adminData = await userManagementService.getUsers(); + + // Determine plan name based on config flags + let planName = 'Free'; + if (config?.runningEE) { + planName = 'Enterprise'; + } else if (config?.runningProOrHigher || adminData.premiumEnabled) { + planName = 'Pro'; + } + + setCurrentLicenseInfo({ + planName, + maxUsers: adminData.maxAllowedUsers, + grandfathered: adminData.grandfatheredUserCount > 0, + }); + } catch (err) { + console.error('Failed to fetch license info:', err); + } + }; + + // Check if Stripe is configured + const stripeKey = import.meta.env.VITE_STRIPE_PUBLISHABLE_KEY; + if (!stripeKey || error) { + setUseStaticVersion(true); + fetchLicenseInfo(); + } + }, [error, config]); + + 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( + (plan: PlanTier) => { + if (plan.isContactOnly) { + // Open contact form or redirect to contact page + window.open('mailto:sales@stirlingpdf.com?subject=Enterprise Plan Inquiry', '_blank'); + return; + } + + if (!currentSubscription || plan.id !== currentSubscription.plan.id) { + setSelectedPlan(plan); + setCheckoutOpen(true); + } + }, + [currentSubscription] + ); + + 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); + }, + [refetch] + ); + + const handlePaymentError = useCallback((error: string) => { + console.error('Payment error:', error); + // Error is already displayed in the StripeCheckout component + }, []); + + const handleCheckoutClose = useCallback(() => { + setCheckoutOpen(false); + setSelectedPlan(null); + }, []); + + // Show static version if Stripe is not configured or there's an error + if (useStaticVersion) { + return ; + } + + // Early returns after all hooks are called + if (loading) { + return ( +
+ +
+ ); + } + + if (error) { + // Fallback to static version on error + return ; + } + + if (!plans || !currentSubscription) { + return ( + + Plans data is not available at the moment. + + ); + } + + return ( +
+ {/* Currency Selector */} +
+ + + {t('plan.currency', 'Currency')} + +