Add subcription feature 6

This commit is contained in:
Urtzi Alfaro
2026-01-16 15:19:34 +01:00
parent 6b43116efd
commit 4bafceed0d
35 changed files with 3826 additions and 1789 deletions

View File

@@ -83,8 +83,7 @@ class ApiClient {
// Endpoints that require authentication but not a tenant ID (user-level endpoints)
const noTenantEndpoints = [
'/auth/me/onboarding', // Onboarding endpoints - tenant is created during onboarding
'/auth/me', // User profile endpoints
'/auth/users/', // User profile endpoints - user-level, no tenant context
'/auth/register', // Registration
'/auth/login', // Login
'/geocoding', // Geocoding/address search - utility service, no tenant context

View File

@@ -30,7 +30,34 @@ import {
export class AuthService {
private readonly baseUrl = '/auth';
// User Profile (authenticated)
// Backend: services/auth/app/api/users.py
// ===================================================================
async getProfile(): Promise<UserResponse> {
// Get current user ID from auth store
const { useAuthStore } = await import('../../stores/auth.store');
const user = useAuthStore.getState().user;
if (!user?.id) {
throw new Error('User not authenticated or user ID not available');
}
return apiClient.get<UserResponse>(`${this.baseUrl}/users/${user.id}`);
}
async updateProfile(updateData: UserUpdate): Promise<UserResponse> {
// Get current user ID from auth store
const { useAuthStore } = await import('../../stores/auth.store');
const user = useAuthStore.getState().user;
if (!user?.id) {
throw new Error('User not authenticated or user ID not available');
}
return apiClient.put<UserResponse>(`${this.baseUrl}/users/${user.id}`, updateData);
}
// ATOMIC REGISTRATION: SetupIntent-First Approach
// These methods implement the secure registration flow with 3DS support
// ===================================================================
@@ -104,19 +131,6 @@ export class AuthService {
});
}
// ===================================================================
// User Profile (authenticated)
// Backend: services/auth/app/api/auth_operations.py
// ===================================================================
async getProfile(): Promise<UserResponse> {
return apiClient.get<UserResponse>(`${this.baseUrl}/me`);
}
async updateProfile(updateData: UserUpdate): Promise<UserResponse> {
return apiClient.put<UserResponse>(`${this.baseUrl}/me`, updateData);
}
// ===================================================================
// OPERATIONS: Email Verification
// Backend: services/auth/app/api/auth_operations.py

View File

@@ -179,7 +179,7 @@ export class SubscriptionService {
* Get current usage summary for a tenant
*/
async getUsageSummary(tenantId: string): Promise<UsageSummary> {
return apiClient.get<UsageSummary>(`${this.baseUrl}/subscriptions/${tenantId}/usage`);
return apiClient.get<UsageSummary>(`/tenants/${tenantId}/subscription/usage`);
}
/**
@@ -190,7 +190,7 @@ export class SubscriptionService {
featureName: string
): Promise<FeatureCheckResponse> {
return apiClient.get<FeatureCheckResponse>(
`${this.baseUrl}/subscriptions/${tenantId}/features/${featureName}/check`
`/tenants/${tenantId}/subscription/features/${featureName}`
);
}
@@ -202,23 +202,30 @@ export class SubscriptionService {
quotaType: string,
requestedAmount?: number
): Promise<QuotaCheckResponse> {
// Map quotaType to the existing endpoints in tenant_operations.py
// Map quotaType to the new subscription limit endpoints
let endpoint: string;
switch (quotaType) {
case 'inventory_items':
endpoint = 'can-add-product';
case 'products':
endpoint = 'products';
break;
case 'users':
endpoint = 'can-add-user';
endpoint = 'users';
break;
case 'locations':
endpoint = 'can-add-location';
endpoint = 'locations';
break;
case 'recipes':
endpoint = 'recipes';
break;
case 'suppliers':
endpoint = 'suppliers';
break;
default:
throw new Error(`Unsupported quota type: ${quotaType}`);
}
const url = `${this.baseUrl}/subscriptions/${tenantId}/${endpoint}`;
const url = `/tenants/${tenantId}/subscription/limits/${endpoint}`;
// Get the response from the endpoint (returns different format than expected)
const response = await apiClient.get<{
@@ -242,27 +249,35 @@ export class SubscriptionService {
}
async validatePlanUpgrade(tenantId: string, planKey: string): Promise<PlanUpgradeValidation> {
return apiClient.get<PlanUpgradeValidation>(`${this.baseUrl}/subscriptions/${tenantId}/validate-upgrade/${planKey}`);
return apiClient.get<PlanUpgradeValidation>(`/tenants/${tenantId}/subscription/validate-upgrade/${planKey}`);
}
async upgradePlan(tenantId: string, planKey: string): Promise<PlanUpgradeResult> {
return apiClient.post<PlanUpgradeResult>(`${this.baseUrl}/subscriptions/${tenantId}/upgrade?new_plan=${planKey}`, {});
return apiClient.post<PlanUpgradeResult>(`/tenants/${tenantId}/subscription/upgrade`, { new_plan: planKey });
}
async canAddLocation(tenantId: string): Promise<{ can_add: boolean; reason?: string; current_count?: number; max_allowed?: number }> {
return apiClient.get(`${this.baseUrl}/subscriptions/${tenantId}/can-add-location`);
return apiClient.get(`/tenants/${tenantId}/subscription/limits/locations`);
}
async canAddProduct(tenantId: string): Promise<{ can_add: boolean; reason?: string; current_count?: number; max_allowed?: number }> {
return apiClient.get(`${this.baseUrl}/subscriptions/${tenantId}/can-add-product`);
return apiClient.get(`/tenants/${tenantId}/subscription/limits/products`);
}
async canAddUser(tenantId: string): Promise<{ can_add: boolean; reason?: string; current_count?: number; max_allowed?: number }> {
return apiClient.get(`${this.baseUrl}/subscriptions/${tenantId}/can-add-user`);
return apiClient.get(`/tenants/${tenantId}/subscription/limits/users`);
}
async canAddRecipe(tenantId: string): Promise<{ can_add: boolean; reason?: string; current_count?: number; max_allowed?: number }> {
return apiClient.get(`/tenants/${tenantId}/subscription/limits/recipes`);
}
async canAddSupplier(tenantId: string): Promise<{ can_add: boolean; reason?: string; current_count?: number; max_allowed?: number }> {
return apiClient.get(`/tenants/${tenantId}/subscription/limits/suppliers`);
}
async hasFeature(tenantId: string, featureName: string): Promise<{ has_feature: boolean; feature_value?: any; plan?: string; reason?: string }> {
return apiClient.get(`${this.baseUrl}/subscriptions/${tenantId}/features/${featureName}`);
return apiClient.get(`/tenants/${tenantId}/subscription/features/${featureName}`);
}
formatPrice(amount: number): string {
@@ -348,8 +363,7 @@ export class SubscriptionService {
days_remaining: number;
read_only_mode_starts: string;
}> {
return apiClient.post('/subscriptions/cancel', {
tenant_id: tenantId,
return apiClient.post(`/tenants/${tenantId}/subscription/cancel`, {
reason: reason || ''
});
}
@@ -364,8 +378,7 @@ export class SubscriptionService {
plan: string;
next_billing_date: string | null;
}> {
return apiClient.post('/subscriptions/reactivate', {
tenant_id: tenantId,
return apiClient.post(`/tenants/${tenantId}/subscription/reactivate`, {
plan
});
}
@@ -383,7 +396,7 @@ export class SubscriptionService {
billing_cycle?: string;
next_billing_date?: string;
}> {
return apiClient.get(`/subscriptions/${tenantId}/status`);
return apiClient.get(`/tenants/${tenantId}/subscription/status`);
}
/**
@@ -399,7 +412,7 @@ export class SubscriptionService {
invoice_pdf: string | null;
hosted_invoice_url: string | null;
}>> {
return apiClient.get(`/subscriptions/${tenantId}/invoices`);
return apiClient.get(`/tenants/${tenantId}/subscription/invoices`);
}
/**
@@ -414,7 +427,7 @@ export class SubscriptionService {
exp_year?: number;
} | null> {
try {
const response = await apiClient.get(`/subscriptions/${tenantId}/payment-method`);
const response = await apiClient.get(`/tenants/${tenantId}/subscription/payment-method`);
return response;
} catch (error) {
console.error('Failed to get current payment method:', error);
@@ -440,51 +453,13 @@ export class SubscriptionService {
client_secret?: string;
payment_intent_status?: string;
}> {
return apiClient.post(`/subscriptions/${tenantId}/update-payment-method?payment_method_id=${paymentMethodId}`, {});
}
/**
* Complete subscription creation after SetupIntent confirmation
*
* This method is called after the frontend successfully confirms a SetupIntent
* (with or without 3DS authentication). It verifies the SetupIntent and creates
* the subscription with the verified payment method.
*
* @param setupIntentId - The SetupIntent ID that was confirmed by Stripe
* @param subscriptionData - Data needed to complete subscription creation
* @returns Promise with subscription creation result
*/
async completeSubscriptionAfterSetupIntent(
setupIntentId: string,
subscriptionData: {
customer_id: string;
plan_id: string;
payment_method_id: string;
trial_period_days?: number;
user_id: string;
billing_interval: string;
}
): Promise<{
success: boolean;
message: string;
data: {
subscription_id: string;
customer_id: string;
status: string;
plan: string;
billing_cycle: string;
trial_period_days?: number;
current_period_end: string;
user_id: string;
setup_intent_id: string;
};
}> {
return apiClient.post('/subscriptions/complete-after-setup-intent', {
setup_intent_id: setupIntentId,
...subscriptionData
return apiClient.post(`/tenants/${tenantId}/subscription/payment-method`, {
payment_method_id: paymentMethodId
});
}
// ============================================================================
// NEW METHODS - Usage Forecasting & Predictive Analytics
// ============================================================================

View File

@@ -47,7 +47,7 @@ export class TenantService {
userId: string
): Promise<SubscriptionLinkingResponse> {
return apiClient.post<SubscriptionLinkingResponse>(
`${this.baseUrl}/subscriptions/link`,
`${this.baseUrl}/link-subscription`,
{ tenant_id: tenantId, subscription_id: subscriptionId, user_id: userId }
);
}

View File

@@ -9,7 +9,15 @@ export class UserService {
private readonly baseUrl = '/users';
async getCurrentUser(): Promise<UserResponse> {
return apiClient.get<UserResponse>(`${this.baseUrl}/me`);
// Get current user ID from auth store
const authStore = useAuthStore.getState();
const userId = authStore.user?.id;
if (!userId) {
throw new Error('No authenticated user found');
}
return apiClient.get<UserResponse>(`${this.baseUrl}/${userId}`);
}
async updateUser(userId: string, updateData: UserUpdate): Promise<UserResponse> {

View File

@@ -83,7 +83,7 @@ export const LoginForm: React.FC<LoginFormProps> = ({
});
onSuccess?.();
} catch (err) {
showError(error || 'Email o contraseña incorrectos. Verifica tus credenciales.', {
showToast.error(error || 'Email o contraseña incorrectos. Verifica tus credenciales.', {
title: 'Error al iniciar sesión'
});
}

View File

@@ -558,10 +558,15 @@ const SubscriptionPageRedesign: React.FC = () => {
try {
setInvoicesLoading(true);
const fetchedInvoices = await subscriptionService.getInvoices(tenantId);
setInvoices(fetchedInvoices);
// Ensure fetchedInvoices is an array before setting state
const validatedInvoices = Array.isArray(fetchedInvoices) ? fetchedInvoices : [];
setInvoices(validatedInvoices);
setInvoicesLoaded(true);
} catch (error) {
console.error('Error loading invoices:', error);
// Set invoices to empty array in case of error to prevent slice error
setInvoices([]);
if (invoicesLoaded) {
showToast.error('Error al cargar las facturas');
}
@@ -920,7 +925,7 @@ const SubscriptionPageRedesign: React.FC = () => {
<p className="text-[var(--text-secondary)]">Cargando facturas...</p>
</div>
</div>
) : invoices.length === 0 ? (
) : (!Array.isArray(invoices) || invoices.length === 0) ? (
<div className="text-center py-8">
<div className="flex flex-col items-center gap-3">
<div className="p-4 bg-[var(--bg-secondary)] rounded-full">
@@ -943,7 +948,7 @@ const SubscriptionPageRedesign: React.FC = () => {
</tr>
</thead>
<tbody>
{invoices.slice(0, 5).map((invoice) => (
{Array.isArray(invoices) && invoices.slice(0, 5).map((invoice) => (
<tr key={invoice.id} className="border-b border-[var(--border-color)] hover:bg-[var(--bg-secondary)] transition-colors">
<td className="py-3 px-4 text-[var(--text-primary)] font-medium">
{new Date(invoice.date).toLocaleDateString('es-ES', {
@@ -979,7 +984,7 @@ const SubscriptionPageRedesign: React.FC = () => {
))}
</tbody>
</table>
{invoices.length > 5 && (
{Array.isArray(invoices) && invoices.length > 5 && (
<div className="mt-4 text-center">
<Button variant="ghost" onClick={() => setShowBilling(true)}>
Ver todas las facturas ({invoices.length})