Improve subcription support

This commit is contained in:
Urtzi Alfaro
2025-09-01 19:21:12 +02:00
parent 72b4f60cf5
commit 6346c4bcb9
18 changed files with 3175 additions and 114 deletions

View File

@@ -218,4 +218,5 @@ class ApiClient {
}
}
export { ApiClient };
export const apiClient = new ApiClient();

View File

@@ -1,5 +1,6 @@
// Export API client and types
export * from './client';
export { ApiClient } from './client';
// Export all services
export * from './auth.service';
@@ -14,6 +15,7 @@ export * from './pos.service';
export * from './data.service';
export * from './training.service';
export * from './notification.service';
export * from './subscription.service';
// Service instances for easy importing
export { authService } from './auth.service';
@@ -28,6 +30,7 @@ export { posService } from './pos.service';
export { dataService } from './data.service';
export { trainingService } from './training.service';
export { notificationService } from './notification.service';
export { subscriptionService } from './subscription.service';
// API client instance
export { apiClient } from './client';

View File

@@ -0,0 +1,481 @@
/**
* Subscription Service
* Handles API calls for subscription management, billing, and plan limits
*/
import { ApiClient } from './client';
import { isMockMode, getMockSubscription } from '../../config/mock.config';
export interface SubscriptionLimits {
plan: string;
max_users: number;
max_locations: number;
max_products: number;
features: Record<string, any>;
}
export interface UsageSummary {
plan: string;
monthly_price: number;
status: string;
usage: {
users: {
current: number;
limit: number;
unlimited: boolean;
usage_percentage: number;
};
locations: {
current: number;
limit: number;
unlimited: boolean;
usage_percentage: number;
};
products: {
current: number;
limit: number;
unlimited: boolean;
usage_percentage: number;
};
};
features: Record<string, any>;
next_billing_date?: string;
trial_ends_at?: string;
}
export interface LimitCheckResult {
can_add: boolean;
current_count?: number;
max_allowed?: number;
reason: string;
}
export interface FeatureCheckResult {
has_feature: boolean;
feature_value?: any;
plan: string;
reason: string;
}
export interface PlanUpgradeValidation {
can_upgrade: boolean;
current_plan?: string;
new_plan?: string;
price_change?: number;
new_features?: Record<string, any>;
reason: string;
}
export interface AvailablePlan {
name: string;
description: string;
monthly_price: number;
max_users: number;
max_locations: number;
max_products: number;
features: Record<string, any>;
trial_available: boolean;
popular?: boolean;
contact_sales?: boolean;
}
export interface AvailablePlans {
plans: Record<string, AvailablePlan>;
}
export interface BillingHistoryItem {
id: string;
date: string;
amount: number;
status: 'paid' | 'pending' | 'failed';
description: string;
}
export interface SubscriptionData {
id: string;
tenant_id: string;
plan: string;
status: string;
monthly_price: number;
currency: string;
billing_cycle: string;
current_period_start: string;
current_period_end: string;
next_billing_date: string;
trial_ends_at?: string | null;
canceled_at?: string | null;
created_at: string;
updated_at: string;
max_users: number;
max_locations: number;
max_products: number;
features: Record<string, any>;
usage: {
users: number;
locations: number;
products: number;
storage_gb: number;
api_calls_month: number;
reports_generated: number;
};
billing_history: BillingHistoryItem[];
}
class SubscriptionService {
private apiClient: ApiClient;
constructor() {
this.apiClient = new ApiClient();
}
/**
* Get current subscription limits for a tenant
*/
async getSubscriptionLimits(tenantId: string): Promise<SubscriptionLimits> {
if (isMockMode()) {
const mockSub = getMockSubscription();
return {
plan: mockSub.plan,
max_users: mockSub.max_users,
max_locations: mockSub.max_locations,
max_products: mockSub.max_products,
features: mockSub.features
};
}
const response = await this.apiClient.get(`/subscriptions/${tenantId}/limits`);
return response.data;
}
/**
* Get usage summary vs limits for a tenant
*/
async getUsageSummary(tenantId: string): Promise<UsageSummary> {
if (isMockMode()) {
console.log('🧪 Mock mode: Returning usage summary for tenant:', tenantId);
const mockSub = getMockSubscription();
return {
plan: mockSub.plan,
monthly_price: mockSub.monthly_price,
status: mockSub.status,
usage: {
users: {
current: mockSub.usage.users,
limit: mockSub.max_users,
unlimited: mockSub.max_users === -1,
usage_percentage: mockSub.max_users === -1 ? 0 : Math.round((mockSub.usage.users / mockSub.max_users) * 100)
},
locations: {
current: mockSub.usage.locations,
limit: mockSub.max_locations,
unlimited: mockSub.max_locations === -1,
usage_percentage: mockSub.max_locations === -1 ? 0 : Math.round((mockSub.usage.locations / mockSub.max_locations) * 100)
},
products: {
current: mockSub.usage.products,
limit: mockSub.max_products,
unlimited: mockSub.max_products === -1,
usage_percentage: mockSub.max_products === -1 ? 0 : Math.round((mockSub.usage.products / mockSub.max_products) * 100)
}
},
features: mockSub.features,
next_billing_date: mockSub.next_billing_date,
trial_ends_at: mockSub.trial_ends_at
};
}
const response = await this.apiClient.get(`/subscriptions/${tenantId}/usage`);
return response.data;
}
/**
* Check if tenant can add another location
*/
async canAddLocation(tenantId: string): Promise<LimitCheckResult> {
if (isMockMode()) {
const mockSub = getMockSubscription();
const canAdd = mockSub.max_locations === -1 || mockSub.usage.locations < mockSub.max_locations;
return {
can_add: canAdd,
current_count: mockSub.usage.locations,
max_allowed: mockSub.max_locations,
reason: canAdd ? 'Can add more locations' : 'Location limit reached. Upgrade plan to add more locations.'
};
}
const response = await this.apiClient.get(`/subscriptions/${tenantId}/can-add-location`);
return response.data;
}
/**
* Check if tenant can add another product
*/
async canAddProduct(tenantId: string): Promise<LimitCheckResult> {
if (isMockMode()) {
const mockSub = getMockSubscription();
const canAdd = mockSub.max_products === -1 || mockSub.usage.products < mockSub.max_products;
return {
can_add: canAdd,
current_count: mockSub.usage.products,
max_allowed: mockSub.max_products,
reason: canAdd ? 'Can add more products' : 'Product limit reached. Upgrade plan to add more products.'
};
}
const response = await this.apiClient.get(`/subscriptions/${tenantId}/can-add-product`);
return response.data;
}
/**
* Check if tenant can add another user/member
*/
async canAddUser(tenantId: string): Promise<LimitCheckResult> {
if (isMockMode()) {
const mockSub = getMockSubscription();
const canAdd = mockSub.max_users === -1 || mockSub.usage.users < mockSub.max_users;
return {
can_add: canAdd,
current_count: mockSub.usage.users,
max_allowed: mockSub.max_users,
reason: canAdd ? 'Can add more users' : 'User limit reached. Upgrade plan to add more users.'
};
}
const response = await this.apiClient.get(`/subscriptions/${tenantId}/can-add-user`);
return response.data;
}
/**
* Check if tenant has access to a specific feature
*/
async hasFeature(tenantId: string, feature: string): Promise<FeatureCheckResult> {
if (isMockMode()) {
const mockSub = getMockSubscription();
const hasFeature = feature in mockSub.features;
return {
has_feature: hasFeature,
feature_value: hasFeature ? mockSub.features[feature] : null,
plan: mockSub.plan,
reason: hasFeature ? `Feature ${feature} is available in ${mockSub.plan} plan` : `Feature ${feature} is not available in ${mockSub.plan} plan`
};
}
const response = await this.apiClient.get(`/subscriptions/${tenantId}/features/${feature}`);
return response.data;
}
/**
* Validate if tenant can upgrade to a new plan
*/
async validatePlanUpgrade(tenantId: string, newPlan: string): Promise<PlanUpgradeValidation> {
const response = await this.apiClient.get(`/subscriptions/${tenantId}/validate-upgrade/${newPlan}`);
return response.data;
}
/**
* Upgrade subscription plan for a tenant
*/
async upgradePlan(tenantId: string, newPlan: string): Promise<{
success: boolean;
message: string;
validation: PlanUpgradeValidation;
}> {
const response = await this.apiClient.post(`/subscriptions/${tenantId}/upgrade`, null, {
params: { new_plan: newPlan }
});
return response.data;
}
/**
* Get full subscription data including billing history for admin@bakery.com
*/
async getSubscriptionData(tenantId: string): Promise<SubscriptionData> {
if (isMockMode()) {
return getMockSubscription();
}
const response = await this.apiClient.get(`/subscriptions/${tenantId}/details`);
return response.data;
}
/**
* Get billing history for a subscription
*/
async getBillingHistory(tenantId: string): Promise<BillingHistoryItem[]> {
if (isMockMode()) {
return getMockSubscription().billing_history;
}
const response = await this.apiClient.get(`/subscriptions/${tenantId}/billing-history`);
return response.data;
}
/**
* Get all available subscription plans with features and pricing
*/
async getAvailablePlans(): Promise<AvailablePlans> {
if (isMockMode()) {
return {
plans: {
starter: {
name: 'Starter',
description: 'Perfecto para panaderías pequeñas que están comenzando',
monthly_price: 49.0,
max_users: 5,
max_locations: 1,
max_products: 50,
features: {
inventory_management: 'basic',
demand_prediction: 'basic',
production_reports: 'basic',
analytics: 'basic',
support: 'email',
trial_days: 14,
locations: '1_location'
},
trial_available: true
},
professional: {
name: 'Professional',
description: 'Para panaderías en crecimiento que necesitan más control',
monthly_price: 129.0,
max_users: 15,
max_locations: 2,
max_products: -1,
features: {
inventory_management: 'advanced',
demand_prediction: 'ai_92_percent',
production_management: 'complete',
pos_integrated: true,
logistics: 'basic',
analytics: 'advanced',
support: 'priority_24_7',
trial_days: 14,
locations: '1_2_locations'
},
trial_available: true,
popular: true
},
enterprise: {
name: 'Enterprise',
description: 'Para cadenas de panaderías con necesidades avanzadas',
monthly_price: 399.0,
max_users: -1,
max_locations: -1,
max_products: -1,
features: {
inventory_management: 'multi_location',
demand_prediction: 'ai_personalized',
production_optimization: 'capacity',
erp_integration: true,
logistics: 'advanced',
analytics: 'predictive',
api_access: 'personalized',
account_manager: true,
demo: 'personalized',
locations: 'unlimited_obradores'
},
trial_available: false,
contact_sales: true
}
}
};
}
const response = await this.apiClient.get('/plans/available');
return response.data;
}
/**
* Helper method to check if a feature is enabled for current tenant
*/
async isFeatureEnabled(tenantId: string, feature: string): Promise<boolean> {
try {
const result = await this.hasFeature(tenantId, feature);
return result.has_feature;
} catch (error) {
console.error(`Error checking feature ${feature}:`, error);
return false;
}
}
/**
* Helper method to get feature level (basic, advanced, etc.)
*/
async getFeatureLevel(tenantId: string, feature: string): Promise<string | null> {
try {
const result = await this.hasFeature(tenantId, feature);
return result.feature_value || null;
} catch (error) {
console.error(`Error getting feature level for ${feature}:`, error);
return null;
}
}
/**
* Helper method to check if usage is approaching limits
*/
async isUsageNearLimit(tenantId: string, threshold: number = 80): Promise<{
users: boolean;
locations: boolean;
products: boolean;
}> {
try {
const usage = await this.getUsageSummary(tenantId);
return {
users: !usage.usage.users.unlimited && usage.usage.users.usage_percentage >= threshold,
locations: !usage.usage.locations.unlimited && usage.usage.locations.usage_percentage >= threshold,
products: !usage.usage.products.unlimited && usage.usage.products.usage_percentage >= threshold,
};
} catch (error) {
console.error('Error checking usage limits:', error);
return { users: false, locations: false, products: false };
}
}
/**
* Helper method to format pricing for display
*/
formatPrice(price: number, currency: string = 'EUR'): string {
return new Intl.NumberFormat('es-ES', {
style: 'currency',
currency: currency,
minimumFractionDigits: 0,
maximumFractionDigits: 2,
}).format(price);
}
/**
* Helper method to get plan display information
*/
getPlanDisplayInfo(plan: string): {
name: string;
color: string;
badge?: string;
} {
const planInfo = {
starter: {
name: 'Starter',
color: 'blue',
},
professional: {
name: 'Professional',
color: 'purple',
badge: 'Más Popular'
},
enterprise: {
name: 'Enterprise',
color: 'gold',
}
};
return planInfo[plan as keyof typeof planInfo] || {
name: plan,
color: 'gray'
};
}
}
export const subscriptionService = new SubscriptionService();
export default subscriptionService;