mirror of
https://github.com/Frooodle/Stirling-PDF.git
synced 2026-03-13 02:18:16 +01:00
<img width="698" height="240" alt="image" src="https://github.com/user-attachments/assets/f0161e5f-e2ed-44c1-bdd1-93fab46f756b" /> --------- Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
541 lines
16 KiB
TypeScript
541 lines
16 KiB
TypeScript
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)
|
|
email?: string; // Customer email for checkout pre-fill
|
|
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' | 'SERVER' | 'ENTERPRISE';
|
|
enabled: boolean;
|
|
maxUsers: number;
|
|
hasKey: boolean;
|
|
licenseKey?: string; // The actual license key (for upgrades)
|
|
}
|
|
|
|
export interface LicenseSaveResponse {
|
|
success: boolean;
|
|
licenseType?: string;
|
|
filename?: string;
|
|
filePath?: string;
|
|
enabled?: boolean;
|
|
maxUsers?: number;
|
|
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 = 'usd'): 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,
|
|
email: request.email,
|
|
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;
|
|
}
|
|
},
|
|
|
|
/**
|
|
* Upload license certificate file for offline activation
|
|
* @param file - The .lic or .cert file to upload
|
|
* @returns Promise with upload result
|
|
*/
|
|
async saveLicenseFile(file: File): Promise<LicenseSaveResponse> {
|
|
try {
|
|
const formData = new FormData();
|
|
formData.append('file', file);
|
|
|
|
const response = await apiClient.post('/api/v1/admin/license-file', formData, {
|
|
headers: {
|
|
'Content-Type': 'multipart/form-data',
|
|
},
|
|
});
|
|
|
|
return response.data;
|
|
} catch (error) {
|
|
console.error('Error uploading license file:', 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;
|
|
}
|
|
},
|
|
|
|
/**
|
|
* Update enterprise seat count
|
|
* Creates a Stripe billing portal session for confirming seat changes
|
|
* @param newSeatCount - New number of seats
|
|
* @param licenseKey - Current license key for authentication
|
|
* @returns Billing portal URL for confirming the change
|
|
*/
|
|
async updateEnterpriseSeats(newSeatCount: number, licenseKey: string): Promise<string> {
|
|
// Check if Supabase is configured
|
|
if (!isSupabaseConfigured || !supabase) {
|
|
throw new Error('Supabase is not configured. Seat updates are not available.');
|
|
}
|
|
|
|
const baseUrl = window.location.origin;
|
|
const returnUrl = `${baseUrl}/settings/adminPlan?seats_updated=true`;
|
|
|
|
const { data, error } = await supabase.functions.invoke('manage-billing', {
|
|
body: {
|
|
return_url: returnUrl,
|
|
license_key: licenseKey,
|
|
self_hosted: true,
|
|
new_seat_count: newSeatCount,
|
|
},
|
|
});
|
|
|
|
if (error) {
|
|
throw new Error(`Failed to update seat count: ${error.message}`);
|
|
}
|
|
|
|
if (!data || !data.url) {
|
|
throw new Error('No billing portal URL returned');
|
|
}
|
|
|
|
return data.url;
|
|
},
|
|
};
|
|
|
|
/**
|
|
* 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';
|
|
}
|
|
|
|
// SERVER type (unlimited users) = Server tier
|
|
if (licenseInfo.licenseType === 'SERVER') {
|
|
return 'server';
|
|
}
|
|
|
|
// ENTERPRISE type (with seats) = Enterprise tier
|
|
if (licenseInfo.licenseType === 'ENTERPRISE' && licenseInfo.maxUsers > 0) {
|
|
return 'enterprise';
|
|
}
|
|
|
|
// Default fallback
|
|
return 'free';
|
|
};
|
|
|
|
export default licenseService;
|