diff --git a/frontend/src/api/services/auth.ts b/frontend/src/api/services/auth.ts index a2e9c863..431dfa38 100644 --- a/frontend/src/api/services/auth.ts +++ b/frontend/src/api/services/auth.ts @@ -41,6 +41,19 @@ export class AuthService { return apiClient.post(`${this.baseUrl}/register-with-subscription`, userData); } + async completeRegistrationAfterSetupIntent(completionData: { + email: string; + password: string; + full_name: string; + setup_intent_id: string; + plan_id: string; + payment_method_id: string; + billing_interval: 'monthly' | 'yearly'; + coupon_code?: string; + }): Promise { + return apiClient.post(`${this.baseUrl}/complete-registration-after-setup-intent`, completionData); + } + async login(loginData: UserLogin): Promise { return apiClient.post(`${this.baseUrl}/login`, loginData); } diff --git a/frontend/src/api/services/subscription.ts b/frontend/src/api/services/subscription.ts index 758b4694..9f56060f 100644 --- a/frontend/src/api/services/subscription.ts +++ b/frontend/src/api/services/subscription.ts @@ -402,6 +402,26 @@ export class SubscriptionService { return apiClient.get(`/subscriptions/${tenantId}/invoices`); } + /** + * Get the current payment method for a subscription + */ + async getCurrentPaymentMethod( + tenantId: string + ): Promise<{ + brand: string; + last4: string; + exp_month?: number; + exp_year?: number; + } | null> { + try { + const response = await apiClient.get(`/subscriptions/${tenantId}/payment-method`); + return response; + } catch (error) { + console.error('Failed to get current payment method:', error); + return null; + } + } + /** * Update the default payment method for a subscription */ @@ -416,10 +436,55 @@ export class SubscriptionService { last4: string; exp_month?: number; exp_year?: number; + requires_action?: boolean; + 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 + }); + } + // ============================================================================ // NEW METHODS - Usage Forecasting & Predictive Analytics // ============================================================================ diff --git a/frontend/src/api/types/auth.ts b/frontend/src/api/types/auth.ts index fed18088..c5c0fcaf 100644 --- a/frontend/src/api/types/auth.ts +++ b/frontend/src/api/types/auth.ts @@ -75,6 +75,18 @@ export interface UserRegistration { */ export interface UserRegistrationWithSubscriptionResponse extends TokenResponse { subscription_id?: string | null; // ID of the created subscription (returned if subscription was created during registration) + requires_action?: boolean | null; // Whether 3DS/SetupIntent authentication is required + action_type?: string | null; // Type of action required (e.g., 'setup_intent_confirmation') + client_secret?: string | null; // Client secret for SetupIntent/PaymentIntent authentication + payment_intent_id?: string | null; // Payment intent ID (deprecated, use setup_intent_id) + setup_intent_id?: string | null; // SetupIntent ID for 3DS authentication + customer_id?: string | null; // Stripe customer ID (needed for completion) + plan_id?: string | null; // Plan ID (needed for completion) + payment_method_id?: string | null; // Payment method ID (needed for completion) + trial_period_days?: number | null; // Trial period days (needed for completion) + user_id?: string | null; // User ID (needed for completion) + billing_interval?: string | null; // Billing interval (needed for completion) + message?: string | null; // Message explaining what needs to be done } /** diff --git a/frontend/src/components/domain/auth/PaymentForm.tsx b/frontend/src/components/domain/auth/PaymentForm.tsx index bf46b7d4..e68a77eb 100644 --- a/frontend/src/components/domain/auth/PaymentForm.tsx +++ b/frontend/src/components/domain/auth/PaymentForm.tsx @@ -70,6 +70,52 @@ const PaymentForm: React.FC = ({ return; } + // Perform client-side validation before sending to Stripe + if (!billingDetails.name.trim()) { + setError('El nombre del titular es obligatorio'); + onPaymentError('El nombre del titular es obligatorio'); + setLoading(false); + return; + } + + if (!billingDetails.email.trim()) { + setError('El correo electrónico es obligatorio'); + onPaymentError('El correo electrónico es obligatorio'); + setLoading(false); + return; + } + + // Basic email validation + const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + if (!emailRegex.test(billingDetails.email)) { + setError('Por favor, ingrese un correo electrónico válido'); + onPaymentError('Por favor, ingrese un correo electrónico válido'); + setLoading(false); + return; + } + + // Validate required address fields + if (!billingDetails.address.line1.trim()) { + setError('La dirección es obligatoria'); + onPaymentError('La dirección es obligatoria'); + setLoading(false); + return; + } + + if (!billingDetails.address.city.trim()) { + setError('La ciudad es obligatoria'); + onPaymentError('La ciudad es obligatoria'); + setLoading(false); + return; + } + + if (!billingDetails.address.postal_code.trim()) { + setError('El código postal es obligatorio'); + onPaymentError('El código postal es obligatorio'); + setLoading(false); + return; + } + setLoading(true); setError(null); @@ -126,6 +172,16 @@ const PaymentForm: React.FC = ({ setCardComplete(event.complete); }; + // Add a cleanup function to ensure loading state is reset when component unmounts + useEffect(() => { + return () => { + // Ensure loading is reset when component unmounts to prevent stuck states + if (loading) { + setLoading(false); + } + }; + }, []); + return (
diff --git a/frontend/src/components/domain/auth/RegisterForm.tsx b/frontend/src/components/domain/auth/RegisterForm.tsx index 9c96af3e..f65341ff 100644 --- a/frontend/src/components/domain/auth/RegisterForm.tsx +++ b/frontend/src/components/domain/auth/RegisterForm.tsx @@ -75,6 +75,18 @@ export const RegisterForm: React.FC = ({ const [errors, setErrors] = useState>({}); const [showPassword, setShowPassword] = useState(false); const [showConfirmPassword, setShowConfirmPassword] = useState(false); + const [loading, setLoading] = useState(false); // Local loading state for 3DS flow + + // State for pending subscription data (needed for post-3DS completion) + const [pendingSubscriptionData, setPendingSubscriptionData] = useState<{ + customer_id: string; + plan_id: string; + payment_method_id: string; + trial_period_days?: number; + user_id: string; + billing_interval: string; + setup_intent_id?: string; + } | null>(null); const { register, registerWithSubscription } = useAuthActions(); const isLoading = useAuthLoading(); @@ -144,14 +156,76 @@ export const RegisterForm: React.FC = ({ // directly to backend via secure API calls. // Clean up any old registration_progress data on mount (security fix) + // Also check for Stripe 3DS redirect parameters useEffect(() => { - try { - localStorage.removeItem('registration_progress'); - localStorage.removeItem('wizardState'); // Clean up wizard state too - } catch (err) { - console.error('Error cleaning up old localStorage data:', err); + // Prevent multiple executions of 3DS redirect handling + const hasProcessedRedirect = sessionStorage.getItem('3ds_redirect_processed'); + + if (hasProcessedRedirect) { + return; } - }, []); + + // Clean up old data + localStorage.removeItem('registration_progress'); + localStorage.removeItem('wizardState'); + + // Check if this is a Stripe redirect after 3DS authentication + const urlParams = new URLSearchParams(window.location.search); + const setupIntent = urlParams.get('setup_intent'); + const redirectStatus = urlParams.get('redirect_status'); + + if (setupIntent && redirectStatus) { + console.log('3DS redirect detected:', { setupIntent, redirectStatus }); + + // Mark as processed + sessionStorage.setItem('3ds_redirect_processed', 'true'); + + // Clean the URL immediately + const cleanUrl = window.location.pathname; + window.history.replaceState({}, '', cleanUrl); + + if (redirectStatus === 'succeeded') { + // Retrieve pending registration data (NEW ARCHITECTURE) + const pendingDataStr = sessionStorage.getItem('pending_registration_data'); + + if (pendingDataStr) { + try { + setLoading(true); + const pendingData = JSON.parse(pendingDataStr); + console.log('3DS redirect detected - completing registration (NEW ARCHITECTURE)'); + + // Complete the registration (create user and subscription) + completeRegistrationAfterSetupIntent(setupIntent, pendingData); + } catch (err) { + console.error('Error parsing pending registration data:', err); + showToast.error('Error al procesar datos de registro', { + title: 'Error' + }); + setLoading(false); + } + } else { + console.warn('No pending registration data found after 3DS redirect'); + showToast.error('No se encontraron datos de registro pendientes', { + title: 'Error' + }); + setLoading(false); + } + } else if (redirectStatus === 'failed') { + sessionStorage.removeItem('pending_registration_data'); + showToast.error('La autenticación 3D Secure ha fallado', { + title: 'Error de autenticación' + }); + setCurrentStep('payment'); + setLoading(false); + } + + // Clear the flag + setTimeout(() => { + sessionStorage.removeItem('3ds_redirect_processed'); + }, 1000); + } + }, []); // eslint-disable-line react-hooks/exhaustive-deps + const validateForm = (): boolean => { const newErrors: Partial = {}; @@ -219,31 +293,71 @@ export const RegisterForm: React.FC = ({ const handleRegistrationSubmit = async (paymentMethodId?: string) => { try { + setLoading(true); + const registrationData = { full_name: formData.full_name, email: formData.email, password: formData.password, - tenant_name: 'Default Bakery', // Default value since we're not collecting it + tenant_name: 'Default Bakery', subscription_plan: selectedPlan, - billing_cycle: billingCycle, // Add billing cycle selection + billing_cycle: billingCycle, payment_method_id: paymentMethodId, - // Include coupon code if pilot customer coupon_code: isPilot ? couponCode : undefined, - // Include consent data terms_accepted: formData.acceptTerms, privacy_accepted: formData.acceptTerms, marketing_consent: formData.marketingConsent, analytics_consent: formData.analyticsConsent, - // NEW: Include billing address data for subscription creation address: formData.address, postal_code: formData.postal_code, city: formData.city, country: formData.country, }; - // Use the new registration endpoint with subscription creation - await registerWithSubscription(registrationData); + // Call registration endpoint + const response = await registerWithSubscription(registrationData); + // Validate response exists + if (!response) { + throw new Error('Registration failed: No response from server'); + } + + // NEW ARCHITECTURE: Check if SetupIntent verification is required + if (response.requires_action && response.client_secret) { + console.log('SetupIntent verification required (NEW ARCHITECTURE - no user created yet):', { + action_type: response.action_type, + setup_intent_id: response.setup_intent_id, + customer_id: response.customer_id, + email: response.email + }); + + // Store pending registration data for post-3DS completion + const pendingData = { + email: registrationData.email, + password: registrationData.password, + full_name: registrationData.full_name, + setup_intent_id: response.setup_intent_id!, + plan_id: response.plan_id!, + payment_method_id: paymentMethodId!, + billing_interval: billingCycle, + coupon_code: registrationData.coupon_code, + customer_id: response.customer_id!, + payment_customer_id: response.payment_customer_id!, + trial_period_days: response.trial_period_days ?? undefined, + client_secret: response.client_secret! + }; + + setPendingSubscriptionData(pendingData); + sessionStorage.setItem('pending_registration_data', JSON.stringify(pendingData)); + + setLoading(false); + + // Handle SetupIntent confirmation (with possible 3DS) + await handleSetupIntentConfirmation(response.client_secret, paymentMethodId!, pendingData); + return; + } + + // Subscription created successfully without 3DS const successMessage = isPilot ? '¡Bienvenido al programa piloto! Tu cuenta ha sido creada con 3 meses gratis.' : '¡Bienvenido! Tu cuenta ha sido creada correctamente.'; @@ -251,9 +365,12 @@ export const RegisterForm: React.FC = ({ showToast.success(t('auth:register.registering', successMessage), { title: t('auth:alerts.success_create', 'Cuenta creada exitosamente') }); + onSuccess?.(); } catch (err) { - showToast.error(error || t('auth:register.register_button', 'No se pudo crear la account. Verifica que el email no esté en uso.'), { + setLoading(false); + const errorMessage = err instanceof Error ? err.message : 'No se pudo crear la cuenta'; + showToast.error(errorMessage, { title: t('auth:alerts.error_create', 'Error al crear la cuenta') }); } @@ -269,6 +386,141 @@ export const RegisterForm: React.FC = ({ }); }; + // Handle SetupIntent confirmation with 3DS support + const handleSetupIntentConfirmation = async ( + clientSecret: string, + paymentMethodId: string, + pendingData: { + email: string; + password: string; + full_name: string; + setup_intent_id: string; + plan_id: string; + payment_method_id: string; + billing_interval: string; + coupon_code?: string; + customer_id: string; + payment_customer_id: string; + trial_period_days?: number; + client_secret: string; + } + ) => { + try { + setLoading(true); + const stripeInstance = await stripePromise; + + if (!stripeInstance) { + throw new Error('Stripe.js has not loaded correctly.'); + } + + console.log('Confirming SetupIntent (NEW ARCHITECTURE - no user created yet):', { clientSecret }); + + // Confirm the SetupIntent with 3DS authentication if needed + const returnUrl = `${window.location.origin}${window.location.pathname}`; + + const { error: stripeError, setupIntent } = await stripeInstance.confirmCardSetup(clientSecret, { + payment_method: paymentMethodId, + return_url: returnUrl + }); + + if (stripeError) { + console.error('SetupIntent confirmation error:', stripeError); + showToast.error(stripeError.message || 'Error en la verificación del método de pago', { + title: 'Error de autenticación' + }); + setLoading(false); + return; + } + + // Check if setup intent succeeded (no 3DS required, or 3DS completed in modal) + if (setupIntent && setupIntent.status === 'succeeded') { + console.log('SetupIntent succeeded, completing registration (NEW ARCHITECTURE - creating user now)'); + await completeRegistrationAfterSetupIntent(setupIntent.id, pendingData); + } else if (setupIntent && setupIntent.status === 'requires_action') { + // 3DS redirect happened - the completion will be handled in URL redirect handler + console.log('SetupIntent requires 3DS redirect, waiting for return...'); + // Don't set loading to false - keep spinner while redirecting + } else { + console.error('Unexpected SetupIntent status:', setupIntent?.status); + showToast.error(`Estado inesperado: ${setupIntent?.status}`, { + title: 'Error' + }); + setLoading(false); + } + } catch (err) { + console.error('Error during SetupIntent confirmation:', err); + const errorMessage = err instanceof Error ? err.message : 'Error durante la verificación'; + showToast.error(errorMessage, { + title: 'Error de autenticación' + }); + setLoading(false); + } + }; + + // Complete subscription creation after SetupIntent confirmation + // Complete registration after SetupIntent confirmation (NEW ARCHITECTURE) + const completeRegistrationAfterSetupIntent = async ( + setupIntentId: string, + pendingData: { + email: string; + password: string; + full_name: string; + setup_intent_id: string; + plan_id: string; + payment_method_id: string; + billing_interval: string; + coupon_code?: string; + customer_id: string; + payment_customer_id: string; + trial_period_days?: number; + client_secret: string; + } + ) => { + try { + console.log('Completing registration after SetupIntent confirmation (NEW ARCHITECTURE - creating user now)'); + + // Call backend to complete registration (create user and subscription) + const completionData = { + email: pendingData.email, + password: pendingData.password, + full_name: pendingData.full_name, + setup_intent_id: setupIntentId, + plan_id: pendingData.plan_id, + payment_method_id: pendingData.payment_method_id, + billing_interval: pendingData.billing_interval, + coupon_code: pendingData.coupon_code + }; + + const response = await completeRegistrationAfterSetupIntent(completionData); + + if (response.access_token) { + console.log('Registration completed successfully - user created and authenticated'); + const successMessage = isPilot + ? '¡Bienvenido al programa piloto! Tu cuenta ha sido creada con 3 meses gratis.' + : '¡Bienvenido! Tu cuenta ha sido creada correctamente.'; + + showToast.success(t('auth:register.registering', successMessage), { + title: t('auth:alerts.success_create', 'Cuenta creada exitosamente') + }); + + // Clear pending registration data + sessionStorage.removeItem('pending_registration_data'); + setPendingSubscriptionData(null); + + onSuccess?.(); + } else { + throw new Error('Registration completion failed: No access token received'); + } + } catch (err) { + setLoading(false); + const errorMessage = err instanceof Error ? err.message : 'Error completando el registro'; + showToast.error(errorMessage, { + title: 'Error de registro' + }); + console.error('Error completing registration:', err); + } + }; + const handleInputChange = (field: keyof SimpleUserRegistration) => (e: React.ChangeEvent) => { const value = e.target.type === 'checkbox' ? e.target.checked : e.target.value; setFormData(prev => ({ ...prev, [field]: value })); diff --git a/frontend/src/components/subscription/PaymentMethodUpdateModal.tsx b/frontend/src/components/subscription/PaymentMethodUpdateModal.tsx index 091b450e..f70a8c28 100644 --- a/frontend/src/components/subscription/PaymentMethodUpdateModal.tsx +++ b/frontend/src/components/subscription/PaymentMethodUpdateModal.tsx @@ -33,6 +33,7 @@ export const PaymentMethodUpdateModal: React.FC = }) => { const { t } = useTranslation('subscription'); const [loading, setLoading] = useState(false); + const [authenticating, setAuthenticating] = useState(false); const [paymentMethodId, setPaymentMethodId] = useState(''); const [stripe, setStripe] = useState(null); const [elements, setElements] = useState(null); @@ -141,24 +142,139 @@ export const PaymentMethodUpdateModal: React.FC = // Call backend to update payment method const result = await subscriptionService.updatePaymentMethod(tenantId, paymentMethod.id); - if (result.success) { - setSuccess(true); - showToast.success(result.message); - - // Notify parent component about the update - onPaymentMethodUpdated({ - brand: result.brand, - last4: result.last4, - exp_month: result.exp_month, - exp_year: result.exp_year, + // Log 3DS requirement status + if (result.requires_action) { + console.log('3DS authentication required for payment method update', { + payment_method_id: paymentMethod.id, + setup_intent_id: result.payment_intent_id, + action_required: result.requires_action }); - - // Close modal after a brief delay to show success message - setTimeout(() => { - onClose(); - }, 2000); + } + + if (result.success) { + // Check if 3D Secure authentication is required + if (result.requires_action && result.client_secret) { + setAuthenticating(true); + setLoading(false); + setError(null); + + console.log('Starting 3DS authentication process', { + client_secret: result.client_secret.substring(0, 20) + '...', // Log partial secret for security + payment_method_id: paymentMethod.id, + timestamp: new Date().toISOString() + }); + + try { + // Handle 3D Secure authentication + const { error: confirmError, setupIntent } = await stripe.confirmCardSetup(result.client_secret); + + console.log('3DS authentication completed', { + setup_intent_status: setupIntent?.status, + error: confirmError ? confirmError.message : 'none', + timestamp: new Date().toISOString() + }); + + if (confirmError) { + // Handle specific 3D Secure error types + if (confirmError.type === 'card_error') { + setError(confirmError.message || 'Card authentication failed'); + } else if (confirmError.type === 'validation_error') { + setError('Invalid authentication request'); + } else if (confirmError.type === 'api_error') { + setError('Authentication service unavailable'); + } else { + setError(confirmError.message || '3D Secure authentication failed'); + } + setAuthenticating(false); + return; + } + + // Check setup intent status after authentication + if (setupIntent && setupIntent.status === 'succeeded') { + setSuccess(true); + showToast.success('Payment method updated and authenticated successfully'); + + console.log('3DS authentication successful', { + payment_method_id: paymentMethod.id, + setup_intent_id: setupIntent.id, + setup_intent_status: setupIntent.status, + timestamp: new Date().toISOString() + }); + + // Notify parent component about the update + onPaymentMethodUpdated({ + brand: result.brand, + last4: result.last4, + exp_month: result.exp_month, + exp_year: result.exp_year, + }); + + // Close modal after a brief delay to show success message + setTimeout(() => { + onClose(); + }, 2000); + } else { + console.log('3DS authentication completed with non-success status', { + setup_intent_status: setupIntent?.status, + payment_method_id: paymentMethod.id, + timestamp: new Date().toISOString() + }); + + setError('3D Secure authentication completed but payment method not confirmed'); + setLoading(false); + } + + } catch (authError) { + console.error('Error during 3D Secure authentication:', authError); + + // Enhanced error handling for 3DS failures + if (authError instanceof Error) { + if (authError.message.includes('canceled') || authError.message.includes('user closed')) { + setError('3D Secure authentication was canceled. You can try again with the same card.'); + } else if (authError.message.includes('failed') || authError.message.includes('declined')) { + setError('3D Secure authentication failed. Please try again or use a different card.'); + } else if (authError.message.includes('timeout') || authError.message.includes('network')) { + setError('Network error during 3D Secure authentication. Please check your connection and try again.'); + } else { + setError(`3D Secure authentication error: ${authError.message}`); + } + } else { + setError('3D Secure authentication error. Please try again.'); + } + + setAuthenticating(false); + } + } else { + // No authentication required + setSuccess(true); + showToast.success(result.message); + + // Notify parent component about the update + onPaymentMethodUpdated({ + brand: result.brand, + last4: result.last4, + exp_month: result.exp_month, + exp_year: result.exp_year, + }); + + // Close modal after a brief delay to show success message + setTimeout(() => { + onClose(); + }, 2000); + } } else { - setError(result.message || 'Failed to update payment method'); + // Handle different payment intent statuses + if (result.payment_intent_status === 'requires_payment_method') { + setError('Your card was declined. Please try a different payment method.'); + } else if (result.payment_intent_status === 'requires_action') { + setError('Authentication required but client secret missing. Please try again.'); + } else if (result.payment_intent_status === 'processing') { + setError('Payment is processing. Please wait a few minutes and try again.'); + } else if (result.payment_intent_status === 'canceled') { + setError('Payment was canceled. Please try again.'); + } else { + setError(result.message || 'Failed to update payment method'); + } } } catch (err) { @@ -223,10 +339,28 @@ export const PaymentMethodUpdateModal: React.FC =
+ {authenticating && ( +
+ + Completing secure authentication with your bank... +
+ )} + {error && (
{error} + {error.includes('3D Secure') && ( + + )}
)} @@ -242,18 +376,18 @@ export const PaymentMethodUpdateModal: React.FC = type="button" variant="outline" onClick={handleClose} - disabled={loading} + disabled={loading || authenticating} > Cancelar diff --git a/frontend/src/locales/es/auth.json b/frontend/src/locales/es/auth.json index 14e650e5..a619a169 100644 --- a/frontend/src/locales/es/auth.json +++ b/frontend/src/locales/es/auth.json @@ -50,7 +50,17 @@ "next_button": "Siguiente", "previous_button": "Anterior", "have_account": "¿Ya tienes una cuenta?", - "sign_in_link": "Iniciar sesión" + "sign_in_link": "Iniciar sesión", + "3ds_success": "Autenticación 3D Secure completada con éxito", + "3ds_failed": "La autenticación 3D Secure ha fallado", + "3ds_complete": "Tu tarjeta ha sido verificada. Por favor, inicia sesión.", + "3ds_failed_try_again": "La autenticación 3D Secure ha fallado. Por favor, intenta de nuevo con otra tarjeta.", + "processing_3ds": "Procesando autenticación", + "3ds_error": "Error de autenticación", + "3ds_redirect_message": "Tu autenticación 3D Secure se ha completado. Serás redirigido en breve...", + "3ds_failed_message": "Hubo un problema con la autenticación 3D Secure. Serás redirigido para intentar de nuevo...", + "go_to_login": "Ir a inicio de sesión", + "try_again": "Intentar registro de nuevo" }, "steps": { "info": "Información", diff --git a/frontend/src/locales/es/help.json b/frontend/src/locales/es/help.json index d7222aa0..d216965e 100644 --- a/frontend/src/locales/es/help.json +++ b/frontend/src/locales/es/help.json @@ -926,53 +926,296 @@ "inventoryManagement": { "title": "Gestión de Inventario y Control de Stock", "description": "Control completo de ingredientes con FIFO, alertas de stock bajo y reducción de desperdicios", - "readTime": "9", + "readTime": "15", "content": { - "intro": "El inventario rastrea todos tus ingredientes: stock actual, movimientos, proveedores, costos y fechas de caducidad.", + "intro": "El sistema de inventario gestiona TODOS tus materiales en una tabla unificada: ingredientes (harina, levadura, mantequilla) Y productos finales (baguettes, croissants). Incluye tracking de lotes, FIFO automático, alertas inteligentes, trazabilidad completa HACCP, valoración de stock y auditoría. Todo integrado con producción, ventas y compras.", + "unifiedModel": { + "title": "Modelo de Inventario Unificado", + "description": "BakeWise usa UN SOLO catálogo para ingredientes y productos finales (no dos tablas separadas). Beneficios:", + "benefits": [ + "Simplificación: Una sola UI para gestionar todo tu inventario (materias primas + productos terminados)", + "Transformaciones: Puedes transformar ingredientes en productos (harina + agua → masa madre → baguette) dentro del mismo sistema", + "Consistencia: Mismas reglas de stock, caducidad, FIFO aplican a todo", + "Flexibilidad: Un ítem puede ser ingrediente para una receta y producto final vendible (ej: masa madre)" + ], + "structure": { + "title": "Estructura de Datos", + "fields": [ + { + "field": "product_type", + "values": "INGREDIENT (materia prima) o FINISHED_PRODUCT (producto final)", + "example": "Harina T-55 = INGREDIENT, Baguette = FINISHED_PRODUCT" + }, + { + "field": "ingredient_category", + "values": "Para materias primas: FLOURS, DAIRY, YEAST, SUGARS, FATS, SALTS, ADDITIVES, PACKAGING, OTHER", + "example": "Harina T-55 → FLOURS, Mantequilla → DAIRY" + }, + { + "field": "product_category", + "values": "Para productos finales: BREAD, PASTRIES, CAKES, SPECIALS, OTHER", + "example": "Baguette → BREAD, Croissant → PASTRIES" + }, + { + "field": "unit_of_measure", + "values": "kg, g, L, ml, units (unidades), dozen (docena)", + "example": "Harina: kg, Baguettes: units, Leche: L" + } + ] + } + }, + "fifoImplementation": { + "title": "Sistema FIFO (First-In-First-Out) Automático", + "description": "El corazón del control de caducidades. FIFO garantiza que SIEMPRE se usan primero los lotes más antiguos (próximos a caducar).", + "howItWorks": [ + { + "step": "1. Almacenamiento por Lotes", + "description": "Cada recepción de mercancía crea un Stock (lote) independiente con: batch_number (ej: INV-20260113-001), current_quantity (100 kg), expiration_date (2026-02-15), unit_cost (0.85€/kg), received_date (2026-01-13). Múltiples lotes del mismo ingrediente coexisten en el sistema con fechas de caducidad diferentes." + }, + { + "step": "2. Reserva FIFO Automática", + "description": "Cuando producción necesita consumir ingrediente (ej: 50 kg de harina para 200 baguettes): Sistema ejecuta query SQL: SELECT * FROM stock WHERE ingredient_id = X AND is_available = true ORDER BY expiration_date ASC (más antiguo primero). Reserva lotes secuencialmente empezando por el que caduca antes. Ejemplo: Lote 1 (expira 2026-02-10): 30 kg disponibles → reserva 30 kg, Lote 2 (expira 2026-02-18): 70 kg disponibles → reserva 20 kg (restantes de los 50 kg necesarios). Total consumido: 30 kg del lote antiguo + 20 kg del siguiente lote." + }, + { + "step": "3. Consumo y Trazabilidad", + "description": "El sistema consume stock reservado y registra: StockMovement con movement_type = PRODUCTION_USE, reference_number = BATCH-20260113-005 (batch de producción que consumió), consumed_items array con detalles de cada lote consumido (stock_id, quantity_consumed, batch_number, expiration_date). Progressive tracking: quantity_before, quantity_after por lote. Auditoría completa: quién, cuándo, por qué, desde qué lote." + }, + { + "step": "4. Actualización de Cantidades", + "description": "Para cada lote consumido: current_quantity -= consumed_qty, reserved_quantity -= consumed_qty (libera reserva), available_quantity = current_quantity - reserved_quantity, Si current_quantity = 0 → is_available = false (lote agotado, pero mantiene historial). Sistema nunca borra stocks consumidos (trazabilidad HACCP)." + } + ], + "lifoOption": { + "title": "LIFO Opcional (Last-In-First-Out)", + "description": "En casos especiales puedes usar LIFO (último en entrar, primero en salir): POST /consume-stock?fifo=false. Casos de uso: Ingredientes no perecederos donde quieres rotar antiguo (usar nuevo primero para mantener antiguo como buffer), Productos congelados con shelf life muy largo (años). Por defecto: FIFO=true (99% de los casos en panadería)." + }, + "stateBasedExpiration": { + "title": "Fechas de Caducidad State-Dependent", + "description": "Innovación clave: productos transformados tienen DOS fechas de caducidad. Original: expiration_date (fecha del ingrediente base, ej: masa par-baked caduca 2026-03-01), Transformada: final_expiration_date (fecha del producto final tras horneado, ej: masa fully-baked caduca 2026-01-18). Sistema usa final_expiration_date si existe, sino expiration_date. Ejemplo real: Masa par-baked shelf life 45 días → Fully baked shelf life 3 días → FIFO usa fecha de 3 días para producto final." + } + }, "features": [ { - "name": "Seguimiento en Tiempo Real", - "description": "Stock actualizado automáticamente cuando registras producciones o entregas. Ve el stock actual de cada ingrediente en tiempo real" + "name": "Seguimiento en Tiempo Real con Múltiples Lotes", + "description": "Visualización multi-lote en UI: Ingrediente: Harina T-55, Stock Total: 285 kg (suma de todos los lotes activos), Lote 1: 120 kg, expira 2026-02-10, Proveedor A, Lote 2: 95 kg, expira 2026-02-15, Proveedor A, Lote 3: 70 kg, expira 2026-02-20, Proveedor B. Color-coded por urgencia de caducidad: Rojo (expira hoy/mañana), Naranja (2-7 días), Amarillo (8-30 días), Verde (>30 días). Actualización automática: Tras producción batch completado → stock descuenta instantáneamente (WebSocket notify opcional), Tras recepción de mercancía → stock aumenta. Endpoint: GET /api/v1/tenants/{tenant_id}/inventory/stock?ingredient_id={id} → devuelve array de lotes con detalles completos." }, { - "name": "FIFO Automático", - "description": "First-In-First-Out (primero en entrar, primero en salir). El sistema consume automáticamente los lotes más antiguos para evitar caducidades" + "name": "Alertas de Stock Bajo Multi-Nivel", + "description": "Sistema de alertas con 2 umbrales configurables por ingrediente: Umbral 1: low_stock_threshold (alerta preventiva, ej: 80 kg). Genera alerta severity='medium', alert_type='low_stock', Acción sugerida: 'Revisar consumo, considerar pedido'. Umbral 2: reorder_point (alerta crítica, ej: 50 kg). Genera alerta severity='high', alert_type='reorder_needed', Acción sugerida: 'HACER PEDIDO URGENTE', Incluye: suggested_order_quantity (cantidad estándar de pedido), supplier_lead_time_days (tiempo de entrega del proveedor), hours_until_stockout (cuántas horas quedan al ritmo actual de consumo). Cálculo inteligente: Sistema analiza consumo últimos 7 días → daily_usage = avg(consumo_diario), days_of_stock = current_stock / daily_usage. Priorización: Alertas ordenadas por hours_until_stockout ASC (primero el más urgente). Notificaciones: Dashboard badge rojo con número de alertas críticas, Email/WhatsApp (si configurado) para alertas CRITICAL, Recomendación de orden de compra auto-generada. Endpoint: GET /api/v1/tenants/{tenant_id}/inventory/stock/low-stock → devuelve array de ingredientes críticos." }, { - "name": "Alertas de Stock Bajo", - "description": "Cuando un ingrediente llega al punto de reorden, recibes alerta. Ejemplo: 'Harina T-55: stock 15kg, mínimo 50kg, hacer pedido'" + "name": "Gestión Avanzada de Caducidades", + "description": "Sistema multi-stage de alertas de caducidad: CRÍTICO (1-2 días antes): severity='critical', alert_type='urgent_expiry', Acción: 'Usar HOY o marcar como waste', Notificación inmediata. ALTO (3-7 días antes): severity='high', alert_type='expiring_soon', Acción: 'Priorizar uso en producciones', Dashboard alert. MEDIO (8-14 días antes): severity='medium', alert_type='expiring_warning', Acción: 'Planificar consumo', Solo en reportes. Configuración flexible: days_ahead parameter (default: 7, range: 1-365), Personalizable por ingrediente si necesario. Endpoint: GET /api/v1/tenants/{tenant_id}/inventory/stock/expiring?days_ahead=7. Gestión de expirados: Scheduler automático ejecuta diariamente (3 AM): Marca stocks con expiration_date < today como quality_status='expired', Cambia is_available=false (bloquea consumo), Crea alerta inventory.expired_products con lista completa, Genera reporte de waste por caducidad (kg + €). Workflow de waste: Operador revisa expirados en Dashboard → Waste Tracker, Confirma waste → Crea StockMovement type='WASTE' con waste_reason='expired', Stock movido a historial, Métrica de waste actualizada." }, { - "name": "Gestión de Caducidades", - "description": "Registra fechas de caducidad de cada lote. Alertas 7 días antes de caducar. Seguimiento de desperdicio por caducidad" + "name": "Batch/Lot Management Completo", + "description": "Cada lote (Stock) rastrea: Identificación: batch_number (ej: INV-20260113-001, auto-generado), lot_number (número de lote del proveedor, ej: LOT-2026-A123), supplier_batch_ref (referencia interna del proveedor). Fechas: received_date (cuándo llegó), expiration_date (caducidad original), best_before_date (consumir preferentemente antes de), original_expiration_date (fecha pre-transformación), transformation_date (cuándo se transformó, ej: par-baked → fully baked), final_expiration_date (caducidad post-transformación). Calidad: quality_status (good, damaged, expired, quarantined), Si 'quarantined' → bloqueado para uso hasta inspección/aprobación manager. Almacenaje: storage_location (zona de almacén, estante, ej: 'Zona Fría A, Estante 3'), requires_refrigeration (boolean), requires_freezing (boolean), storage_temperature_min/max (ej: 2-8°C para lácteos), storage_humidity_max (ej: 60% para harinas), shelf_life_days (vida útil en días desde received_date). Costos: unit_cost (€/unidad de medida), total_cost (unit_cost × current_quantity), Actualizado automáticamente al consumir stock. UI BatchModal: Lista de todos los lotes de un ingrediente, Cards colapsables (por UX, empiezan collapsed), Acciones por lote: Edit (cantidad, fecha caducidad, ubicación, calidad), Mark as Waste (convierte a WASTE movement), Delete (admin only, soft delete). Endpoint: GET /api/v1/tenants/{tenant_id}/inventory/stock?ingredient_id={id}." + }, + { + "name": "Transformaciones de Productos (Production Stages)", + "description": "Sistema soporta transformar ingredientes a través de etapas de producción: Etapas disponibles: raw_ingredient (comprado del proveedor), prepared_dough (masa preparada pero no horneada), par_baked (pre-horneado), fully_baked (horneado completo), frozen_product (congelado intermedio). Workflow de transformación: Endpoint: POST /api/v1/tenants/{tenant_id}/inventory/transformations, Body: source_ingredient_id (ej: masa par-baked), target_ingredient_id (ej: baguette fully baked), source_quantity (100 unidades par-baked), target_quantity (90 unidades fully baked, 10% loss por merma), expiration_calculation_method: 'days_from_transformation', expiration_days_offset: 3 (baguette fully baked dura 3 días desde horneado). Sistema ejecuta: 1) Reserva source stock usando FIFO, 2) Consume source (crea PRODUCTION_USE movement), 3) Crea nuevo Stock para target con: current_quantity = 90, production_stage = fully_baked, original_expiration_date = fecha del lote par-baked, transformation_date = now(), final_expiration_date = now() + 3 días, 4) Registra Transformation record para trazabilidad. Beneficios: Trazabilidad completa de lote source → target, FIFO usa fecha correcta (3 días final, no 45 días original), Contabilidad precisa de mermas/loss en transformación, Auditoría HACCP (qué lote par-baked generó qué baguettes)." } ], - "stockEntry": [ + "stockOperations": { + "title": "Operaciones de Stock (Entradas, Salidas, Ajustes)", + "movements": [ + { + "type": "PURCHASE (Entrada por Compra)", + "description": "Cuando recibes mercancía del proveedor: POST /api/v1/tenants/{tenant_id}/inventory/stock, Body: ingredient_id, current_quantity (ej: 150 kg), unit_cost (ej: 0.85€/kg), expiration_date (opcional, requerido para perecederos), batch_number (auto-generado si no se provee), supplier_id (opcional), production_stage (default: raw_ingredient), quality_status (default: good), storage_location, storage_temperature_min/max (para refrigerados), notes. Sistema ejecuta: Crea Stock record, Genera batch_number único (formato: INV-YYYYMMDD-NNN), Calcula total_cost = current_quantity × unit_cost, Crea StockMovement type='PURCHASE', movement_type='IN', Actualiza weighted_average_cost del ingrediente: WAC_nuevo = (qty_anterior × WAC_anterior + qty_nueva × cost_nuevo) / (qty_anterior + qty_nueva), Actualiza last_purchase_price, Actualiza total stock available. Tiempo: <200ms. UI: AddStockModal con campos dinámicos según tipo de ingrediente." + }, + { + "type": "PRODUCTION_USE (Salida por Producción)", + "description": "Consumo automático cuando batch de producción se completa: Trigger: ProductionBatch status = COMPLETED, Sistema llama: POST /consume-stock?ingredient_id={id}&quantity=50&reference_number=BATCH-001&fifo=true. Sistema ejecuta: Reserva stock usando FIFO (query ORDER BY expiration_date ASC), Consume secuencialmente de lotes más antiguos, Crea StockMovement por cada lote consumido con: movement_type = PRODUCTION_USE, reference_number = BATCH-20260113-005, quantity_before/after por lote (progressive tracking), batch_info (de qué lotes se consumió, cuánto de cada uno), Devuelve: total_quantity_consumed, consumed_items array [{stock_id, quantity_consumed, batch_number, expiration_date}]. Ejemplo response: {'total_quantity_consumed': 50, 'consumed_items': [{'stock_id': '...', 'quantity_consumed': 30, 'batch_number': 'INV-001', 'expiration_date': '2026-02-10'}, {'stock_id': '...', 'quantity_consumed': 20, 'batch_number': 'INV-002', 'expiration_date': '2026-02-15'}]}. Trazabilidad completa: sabes exactamente qué batch de producción consumió qué lotes de ingredientes." + }, + { + "type": "WASTE (Salida por Desperdicio)", + "description": "Registro de merma, caducidad, daño: UI: BatchModal → Select lote → 'Mark as Waste', Modal aparece: waste_quantity (cuánto se desperdicia, puede ser parcial o total del lote), waste_reason (expired, damaged, contaminated, overproduction, quality_issue, other), notes (descripción libre). POST /api/v1/tenants/{tenant_id}/inventory/stock/movements, Body: movement_type='WASTE', stock_id, quantity, waste_reason, notes. Sistema: Descuenta cantidad del lote, Crea StockMovement type='WASTE', Actualiza métricas de waste (kg, €, % del total), Si quantity = total del lote → is_available=false. Reportes: Dashboard → Waste Tracker → waste_by_reason (expired 60%, damaged 25%, overproduction 15%), waste_trend (últimos 30 días), waste_cost (€ perdidos). Objetivo típico: <5% waste rate (waste/total_purchases)." + }, + { + "type": "TRANSFER (Transferencia entre Ubicaciones)", + "description": "Para multi-tenant (obrador + sucursales) o multi-location (almacén → producción): POST /api/v1/tenants/{tenant_id}/inventory/stock/movements, Body: movement_type='TRANSFER', stock_id, quantity, from_location (ej: 'Almacén Central'), to_location (ej: 'Sucursal Centro'), reference_number (ej: TRANSFER-001), notes. Sistema: Descuenta cantidad de from_location stock, Crea nuevo Stock en to_location con misma info (batch_number, expiration_date, unit_cost copiados), Crea 2 StockMovements: OUT en from_location, IN en to_location, Mantiene trazabilidad (mismo batch_number en ambas ubicaciones). Caso de uso: Obrador produce pan → transfiere a 3 sucursales para venta." + }, + { + "type": "ADJUSTMENT (Ajuste Manual)", + "description": "Correcciones por inventario físico, errores de registro, pérdida no identificada: PUT /api/v1/tenants/{tenant_id}/inventory/stock/{stock_id}, Body: current_quantity (nueva cantidad real tras inventario físico), adjustment_reason (physical_inventory, data_error, loss_unidentified, found_extra), notes (explicación). Sistema: Calcula difference = new_quantity - old_quantity, Crea StockMovement type='ADJUSTMENT', movement_type='IN' si difference > 0, 'OUT' si < 0, quantity = abs(difference), Registra quantity_before, quantity_after, user_id (quién hizo ajuste), created_at (timestamp). Auditoría: Todos los ajustes se revisan en Dashboard → Stock History → Filtrar por ADJUSTMENT. Alertas: Si ajuste > 10% del stock → genera alert para manager review (posible error o theft)." + } + ] + }, + "inventoryValuation": { + "title": "Valoración de Inventario y Contabilidad", + "methods": [ + { + "method": "Weighted Average Cost (WAC) - Método Principal", + "description": "BakeWise usa WAC (Costo Promedio Ponderado) por defecto. Fórmula: WAC = (Stock_Actual × WAC_Actual + Compra_Nueva × Costo_Nuevo) / (Stock_Actual + Compra_Nueva). Ejemplo: Tienes 100 kg harina @ 0.80€/kg (WAC actual), Compras 50 kg @ 1.00€/kg (compra nueva), Cálculo: WAC_nuevo = (100×0.80 + 50×1.00) / (100+50) = (80 + 50) / 150 = 0.867€/kg. Nuevo stock: 150 kg @ 0.867€/kg. Ventajas WAC: Simple y justo (promedia fluctuaciones de precio), Cumple con normativa contable española (PGC-PYMES), Actualización automática en cada compra. Campos en Ingredient: average_cost (WAC actual, usado para valoración), last_purchase_price (último precio pagado, referencia), standard_cost (coste estándar/presupuestado, opcional para variance analysis)." + }, + { + "method": "FIFO Costing (Opcional para Reportes)", + "description": "Aunque valoración usa WAC, puedes generar reportes con FIFO costing para análisis: Costo FIFO = suma de (qty_consumida_lote_i × unit_cost_lote_i) por orden de consumo FIFO. Útil para: Comparar cost_real vs cost_standard, Identificar impacto de variaciones de precio proveedor, Auditorías que requieren método FIFO. Endpoint: GET /api/v1/tenants/{tenant_id}/inventory/analytics?valuation_method=FIFO (genera reporte con FIFO costing). No afecta contabilidad principal (WAC sigue siendo oficial)." + } + ], + "metrics": [ + { + "metric": "Valor Total de Inventario", + "calculation": "Σ(current_quantity × average_cost) para todos los stocks activos (is_available=true)", + "dashboard": "Dashboard → Inventory Summary → Total Stock Value: 12,450€" + }, + { + "metric": "Rotación de Inventario (Inventory Turnover)", + "calculation": "Turnover = Cost_of_Goods_Sold (COGS) / Average_Inventory_Value. Ejemplo: COGS últimos 12 meses: 120,000€, Inventory promedio: 15,000€, Turnover = 120,000 / 15,000 = 8× al año (renuevas inventario 8 veces/año). Benchmark panadería: 12-20× (productos frescos rotan rápido)", + "dashboard": "Dashboard → Analytics → Inventory Turnover: 8.2× (últimos 12 meses)" + }, + { + "metric": "Days of Inventory on Hand (DOH)", + "calculation": "DOH = 365 / Turnover. Ejemplo: Turnover 8× → DOH = 365/8 = 45.6 días promedio. Interpretación: En promedio, un ingrediente permanece en inventario 45 días antes de usarse. Objetivo panadería: 15-30 días (frescos), 30-60 días (secos/no perecederos)", + "dashboard": "Dashboard → Analytics → Days of Inventory: 45.6 días" + }, + { + "metric": "Waste Rate (Tasa de Desperdicio)", + "calculation": "Waste_Rate = (Total_Waste_kg / Total_Purchases_kg) × 100. Ejemplo: Waste últimos 30 días: 50 kg, Purchases últimos 30 días: 1,200 kg, Waste_Rate = (50/1200) × 100 = 4.17%. Benchmark industria: <5% excelente, 5-10% aceptable, >10% problemático", + "dashboard": "Dashboard → Waste Tracker → Waste Rate: 4.2% (último mes, -1.5% vs mes anterior)" + }, + { + "metric": "Waste Cost (Coste de Desperdicio)", + "calculation": "Waste_Cost = Σ(waste_quantity × unit_cost) para todos WASTE movements. Incluye breakdown por waste_reason: expired: 120€ (60%), damaged: 50€ (25%), overproduction: 30€ (15%)", + "dashboard": "Dashboard → Waste Tracker → Total Waste Cost: 200€ (último mes)" + } + ] + }, + "complianceAndAudit": { + "title": "Cumplimiento HACCP y Trazabilidad", + "features": [ + { + "feature": "Trazabilidad Forward & Backward", + "description": "Forward Tracing (de ingrediente a producto final): Pregunta: '¿Dónde fue usado lote INV-001 de harina?' Query: SELECT * FROM stock_movements WHERE stock_id = INV-001 AND movement_type = PRODUCTION_USE. Resultado: Usado en BATCH-20260113-005 (200 baguettes), BATCH-20260113-012 (150 pan rústico). Conclusión: Si lote INV-001 tiene contaminación, sabes exactamente qué productos finales afectó. Backward Tracing (de producto final a ingredientes): Pregunta: '¿Qué lotes de ingredientes se usaron en BATCH-005?' Query: SELECT * FROM stock_movements WHERE reference_number = BATCH-005 AND movement_type = PRODUCTION_USE. Resultado: Harina lote INV-001 (30 kg), Harina lote INV-002 (20 kg), Levadura lote INV-015 (0.8 kg), Sal lote INV-020 (2 kg). Conclusión: Si cliente reporta problema con BATCH-005, rastrear ingredientes exactos usados. Endpoint: GET /api/v1/tenants/{tenant_id}/inventory/stock/movements?stock_id={id} o ?reference_number={batch}. Tiempo de query: <100ms (indexes optimizados)." + }, + { + "feature": "Auditoría Completa de Movimientos", + "description": "Cada StockMovement registra 15+ campos de auditoría: movement_type (PURCHASE, PRODUCTION_USE, WASTE, TRANSFER, ADJUSTMENT), stock_id (qué lote afectado), ingredient_id (qué ingrediente), tenant_id (multi-tenant isolation), user_id (quién ejecutó movimiento), quantity (cuánto movido), movement_date (timestamp preciso con timezone), reference_number (link a orden de compra, batch de producción, etc.), notes (comentarios libres), quantity_before (cantidad antes del movimiento), quantity_after (cantidad después), batch_number (trazabilidad de lote), expiration_date (fecha caducidad del lote afectado), cost_per_unit (valoración en momento del movimiento), total_cost (quantity × cost_per_unit). Export: GET /api/v1/tenants/{tenant_id}/inventory/stock/movements?export=true&format=excel → descarga Excel con TODOS los movimientos filtrados. Filters disponibles: date_range (ej: últimos 90 días para auditoría trimestral), ingredient_id, movement_type, user_id (movimientos de un operador específico). Uso: Auditorías internas/externas, Investigaciones de discrepancias, Análisis de consumo histórico, Compliance HACCP/ISO 22000." + }, + { + "feature": "Food Safety Alerts & Compliance", + "description": "Sistema de alertas específicas de seguridad alimentaria: Temperature Breach: Si temperature_log fuera de rango (storage_temperature_min/max) por >30 min → alerta 'food_safety.temperature_breach'. Data: sensor_id, location (ej: 'Cámara Fría 1'), temperature_actual, temperature_target, duration_minutes, affected_stocks (qué lotes estaban en esa ubicación). Acción: Inspeccionar productos afectados, potencial quarantine. Expired in Cold Chain: Productos refrigerados expirados → severity='critical' (mayor riesgo que secos). Cross-Contamination Risk: Si lote marcado 'allergen:gluten' se mueve a location usada para 'allergen:none' → alerta. Quarantine Management: Lotes con quality_status='quarantined' bloqueados para producción hasta: Manager ejecuta quality inspection, Aprueba (cambia a 'good') o Rechaza (marca como WASTE). Compliance Reports: GET /api/v1/tenants/{tenant_id}/inventory/food-safety/compliance?period=monthly. Genera reporte con: Temperature breach incidents, Expired items timeline, Quarantine actions log, HACCP checklist compliance %. Exportable para auditorías sanitarias." + }, + { + "feature": "Batch Recall Simulation", + "description": "Próximamente: herramienta de simulación de retirada de lote. Input: batch_number (ej: INV-001 contaminado). Sistema calcula automáticamente: Qué productos finales usaron ese lote (forward tracing), Cuántas unidades vendidas de esos productos (link con Sales), A qué clientes (si B2B con customer tracking), Sugerencias de recall notice, Coste estimado de la retirada. Output: Reporte detallado para gestión de crisis. Objetivo: Respuesta <1 hora en caso de alerta sanitaria (vs días manualmente)." + } + ] + }, + "uiComponents": { + "title": "Interfaz de Usuario (Frontend)", + "components": [ + { + "component": "InventoryPage.tsx", + "path": "/dashboard/inventory", + "description": "Página principal de inventario. Vista de tabla: Columnas: Nombre, Categoría, Stock Actual, Unidad, Status (Low Stock, Expiring, OK, Overstock, Expired), Acciones. Filtros: Por categoría (FLOURS, DAIRY, BREAD, PASTRIES, etc.), Por status (low_stock, expiring, expired, all), Búsqueda por nombre (real-time). Status badges: Color-coded (rojo=crítico, naranja=warning, verde=OK), Iconos (⚠️ low stock, ⏰ expiring, ❌ expired, 📦 overstock), Click en badge → detalle modal. Acciones rápidas: + Add Stock (abre AddStockModal), View Batches (abre BatchModal con lotes del ingrediente), View History (abre StockHistoryModal con movimientos), Edit Ingredient (editar nombre, categoría, umbrales). Performance: Paginación 50 items/página, Lazy loading, Cache 2 min (staleTime: 120s). Endpoint: GET /api/v1/tenants/{tenant_id}/inventory/ingredients con stock aggregation." + }, + { + "component": "AddStockModal.tsx", + "description": "Modal para añadir entrada de stock (compra, recepción). Form sections: Ingredient Selection: Dropdown con todos los ingredientes, Autocomplete con búsqueda, Muestra average_cost actual del ingrediente. Quantity & Cost: current_quantity (required, number), unit_cost (opcional, default a average_cost del ingrediente), total_cost (auto-calculado = qty × cost, read-only). Batch Information: batch_number (auto-generado formato INV-YYYYMMDD-NNN, editable), lot_number (número lote proveedor, opcional), supplier_id (dropdown de proveedores, opcional). Expiration & Storage: expiration_date (date picker, requerido si ingrediente es perecedero), best_before_date (opcional), production_stage (raw_ingredient default), quality_status (good default, opciones: good, damaged, quarantined), storage_location (text, ej: 'Almacén A, Estante 2'), requires_refrigeration (checkbox, auto-checked si ingrediente requiere), storage_temperature_min/max (si refrigerado, ej: 2-8°C). Notes: Textarea libre para observaciones. Validations: Quantity > 0, Unit cost >= 0, Expiration date > today (warning si no, permite override), Storage temp dentro de rango aceptable para ingrediente. Submit → POST /api/v1/tenants/{tenant_id}/inventory/stock → Success: Modal cierra, Lista se refresca, Toast notification 'Stock añadido: 150 kg Harina T-55'." + }, + { + "component": "BatchModal.tsx", + "description": "Modal para gestionar lotes de un ingrediente. Layout: Header: Ingrediente: Harina T-55, Stock Total: 285 kg (suma de lotes), Botón 'Add New Batch'. Batch Cards: Array de cards, uno por lote, Collapsible (collapsed por default para UX limpia), Expand al click. Batch Card Content: Batch Number: INV-20260113-001 (bold), Quantity: 120 kg / 150 kg original (current / initial), Expiration: 2026-02-15 (32 días restantes) → color-coded, Supplier: Harinera La Espiga, Cost: 0.85€/kg (102€ total), Quality: good ✓ (badge verde), Location: Almacén A, Estante 2, Storage: Refrigeration not required (icon + text). Actions por batch: Edit: Abre EditBatchModal (cambiar quantity, expiration, location, quality), Mark as Waste: Abre WasteModal (ingresar waste_quantity, waste_reason), Delete: Admin only, confirmación modal, soft delete. Color coding expiration: Rojo: Expired o expira hoy/mañana, Naranja: 2-7 días, Amarillo: 8-30 días, Gris: >30 días. Sorting: Por expiration_date ASC (más urgente primero, FIFO order). Endpoint: GET /api/v1/tenants/{tenant_id}/inventory/stock?ingredient_id={id}." + }, + { + "component": "StockHistoryModal.tsx", + "description": "Modal de historial de movimientos. Filters: Date range picker (default: últimos 30 días), Movement type multi-select (PURCHASE, PRODUCTION_USE, WASTE, TRANSFER, ADJUSTMENT, ALL), User filter (dropdown de usuarios, opcional). Table Columns: Date (timestamp), Type (badge color-coded: PURCHASE=green, PRODUCTION_USE=blue, WASTE=red, TRANSFER=orange, ADJUSTMENT=yellow), Quantity (con + o - prefix según IN/OUT), Batch Number, Reference (link a PO, production batch, etc.), User (quién ejecutó), Notes. Pagination: 20 movimientos/página. Export: Botón 'Export to Excel' → descarga historial filtrado. Drill-down: Click en Reference Number (ej: BATCH-005) → navega a batch detail page, Click en Batch Number (ej: INV-001) → abre BatchModal con ese lote específico. Endpoint: GET /api/v1/tenants/{tenant_id}/inventory/stock/movements?ingredient_id={id}&start_date=X&end_date=Y&movement_type=Z" + }, + { + "component": "WasteTrackerWidget.tsx", + "path": "/dashboard (widget)", + "description": "Widget de dashboard para monitorear desperdicio. Métricas principales: Total Waste (último mes): 50 kg, 200€, Waste Rate: 4.2% (verde si <5%, amarillo 5-10%, rojo >10%), Trend: ↓ -1.5% vs mes anterior (verde). Breakdown por waste_reason: Chart de dona (pie chart) con: Expired: 60% (30 kg), Damaged: 25% (12.5 kg), Overproduction: 10% (5 kg), Other: 5% (2.5 kg). Top 3 wasted ingredients: Harina T-55: 15 kg (30€), Mantequilla: 8 kg (80€, más costoso aunque menos kg), Leche: 12 L (18€). Actions: Click en ingredient → abre detail modal con waste history, Link 'See Full Report' → navega a /analytics/waste. Actualización: Polling cada 5 min (o WebSocket si disponible). Endpoint: GET /api/v1/tenants/{tenant_id}/inventory/analytics/waste?period=30d" + } + ] + }, + "integrationPoints": { + "title": "Integraciones con Otros Módulos", + "integrations": [ + { + "module": "Production Service", + "description": "Bidireccional: Production → Inventory (consumo automático): Cuando ProductionBatch status = COMPLETED, Production Service llama InventoryClient.consume_stock(ingredient_ids, quantities, reference=batch_id, fifo=true), Inventory descuenta stock usando FIFO, devuelve consumed_items detail, Production registra qué lotes se usaron en batch metadata. Inventory → Production (alertas de disponibilidad): Antes de crear batch, Production consulta InventoryClient.check_availability(ingredient_requirements), Si algún ingrediente insuficiente → batch status = ON_HOLD, alerta al manager. Validation en tiempo real: UI de creación de batch muestra stock actual de ingredientes necesarios con color-coding (verde=OK, rojo=insuficiente)." + }, + { + "module": "Sales Service", + "description": "Sales → Inventory (producto final sale): Cuando venta registrada, Sales puede decrementar stock de producto final (opcional, depende de configuración), Si enabled: SalesClient llama InventoryClient.consume_stock(product_id, quantity_sold, reference=sale_id), Útil para tracking de productos finales en inventario (no solo ingredientes). Inventory → Sales (datos para predicciones): Forecasting Service usa inventory levels para ajustar predicciones: Si stock alto de producto X → puede bajar producción sugerida, Si stock bajo → aumentar urgencia de producción." + }, + { + "module": "Procurement Service (Compras)", + "description": "Inventory → Procurement (auto-generación de órdenes): Sistema detecta ingredient con stock < reorder_point, Genera PurchaseOrderSuggestion automática con: ingredient_id, suggested_quantity (= reorder_quantity configurado), supplier_id (proveedor principal del ingrediente), estimated_cost (= suggested_qty × last_purchase_price), urgency (= hours_until_stockout), Manager revisa sugerencias en Dashboard → Procurement → Suggested Orders, Click 'Create PO' → auto-llena formulario de orden de compra. Procurement → Inventory (recepción de mercancía): Cuando PurchaseOrder delivered, Procurement notifica Inventory, UI muestra 'Pending Receipt' para esa PO, Click → auto-llena AddStockModal con datos de la PO (ingredient, quantity, supplier, cost), Confirmar → crea stock entry linked a PO." + }, + { + "module": "Analytics Service", + "description": "Inventory → Analytics (data streaming): Todos los movimientos de stock se publican como eventos a Analytics Service, Analytics agrega datos para: Consumo histórico por ingrediente (tendencias), Waste analytics (causas, costes, trends), Inventory turnover por categoría, Stock value fluctuations. Analytics → Inventory (recomendaciones): Analytics detecta patrones: 'Harina T-55: consumo aumentó 25% últimos 30 días → sugerir aumentar reorder_point de 50 kg a 65 kg', 'Mantequilla: waste por caducidad 15% último mes → sugerir reducir reorder_quantity o negociar compras más frecuentes con proveedor', Dashboard → Inventory → Optimization Recommendations muestra sugerencias AI-powered." + } + ] + }, + "bestPractices": [ { - "title": "Recepción de Pedidos", - "description": "Cuando llega un pedido del proveedor: 1) Registra la entrega en el sistema, 2) Escanea código de barras o introduce cantidad manualmente, 3) Registra lote y fecha de caducidad, 4) Stock se actualiza automáticamente" + "practice": "Configurar Umbrales Realistas", + "description": "Fórmula reorder_point: reorder_point = (daily_usage × supplier_lead_time_days) × safety_factor. Ejemplo: Harina T-55: daily_usage = 50 kg/día (promedio últimos 30 días), lead_time = 2 días (tu proveedor entrega en 2 días), safety_factor = 1.2 (20% buffer para variabilidad), reorder_point = 50 × 2 × 1.2 = 120 kg. Interpretación: Cuando stock baje de 120 kg, hacer pedido. Con consumo de 50 kg/día, tienes 2.4 días de stock (tiempo suficiente para recibir pedido en 2 días). Ajustar safety_factor: 1.1-1.2 (10-20%) para ingredientes estables y proveedores fiables, 1.3-1.5 (30-50%) para ingredientes volátiles o proveedores con lead time variable, 1.5-2.0 (50-100%) para ingredientes críticos sin sustituto." }, { - "title": "Actualización Automática", - "description": "Al completar un lote de producción, el sistema descuenta automáticamente los ingredientes usando FIFO. Si produces 100 baguettes (receta: 500g harina cada), descuenta 50kg de harina del lote más antiguo" - } - ], - "compliance": [ - { - "name": "HACCP", - "description": "Sistema cumple con normas HACCP de seguridad alimentaria. Trazabilidad completa de lotes, temperaturas de almacenaje" + "practice": "Inventario Físico Regular", + "description": "Frecuencia recomendada: Inventario completo: Trimestral (cada 3 meses) para todos los ingredientes, Inventario parcial (ABC analysis): Categoría A (alto valor, 20% ingredientes, 80% valor): Mensual, Categoría B (valor medio, 30% ingredientes, 15% valor): Bimestral, Categoría C (bajo valor, 50% ingredientes, 5% valor): Trimestral. Proceso: 1) Generar reporte de stock teórico: GET /inventory/stock/summary, 2) Contar físicamente (mejor fin de mes cuando stock es bajo), 3) Comparar stock_teorico vs stock_fisico, 4) Si discrepancia > 5%: Investigar (pérdida, theft, error de registro), Ajustar: PUT /stock/{id} con adjustment_reason='physical_inventory', 5) Analizar tendencias de discrepancias (si sistemáticas → problema de proceso). Beneficios: Detecta mermas ocultas (evaporación, derrames, robo), Corrige errores acumulados de registro, Mantiene confianza en datos del sistema, Cumple normativa contable (inventarios auditables)." }, { - "name": "Auditoría", - "description": "Historial completo de movimientos de stock para auditorías. Exportable a PDF/Excel" + "practice": "Rotación FIFO Disciplinada", + "description": "Aunque sistema consume FIFO automáticamente en producción, el almacenaje físico debe facilitar FIFO: Organización de almacén: Lotes nuevos atrás, antiguos adelante (operadores toman de adelante naturalmente), Etiquetado claro: Fecha recepción + Fecha caducidad visible en todos los lotes, Zonas separadas por urgencia: Zona A (expira 0-7 días): Usar PRIMERO, prominente, Zona B (expira 8-30 días): Usar segundo, Zona C (>30 días): Stock de reserva. Auditoría física semanal: Revisar que lotes adelante son realmente los más antiguos (comparar etiquetas vs ubicación), Si encuentras lote antiguo atrás → mover a adelante. Capacitación personal: Entrenar equipo en FIFO importance, Consecuencias de no seguir FIFO (caducidades, waste, coste). Métricas de compliance: Waste_by_expiration: Si >60% del waste es por caducidad → posible problema de FIFO físico (vs otros waste reasons como damaged, overproduction)." + }, + { + "practice": "Monitoreo de Caducidades Proactivo", + "description": "No esperar alertas del sistema, revisar proactivamente: Daily check (5 min cada mañana): Dashboard → Expiring Stock Widget → Items expiring next 3 days, Planificar uso inmediato (incluir en producción del día o día siguiente). Weekly review (15 min cada lunes): GET /stock/expiring?days_ahead=14 → items expiring próximas 2 semanas, Priorizar en plan de producción semanal, Considerar promociones (vender productos con ingredientes próximos a caducar a precio reducido para mover stock rápido). Monthly analysis (30 min fin de mes): Waste report → % waste por expired, Identificar ingredientes con alta tasa de caducidad (compras excesivas? shelf life corto?), Ajustar reorder_quantity o negociar compras más frecuentes/pequeñas con proveedor. Automation: Configurar notificaciones automáticas: Email diario con items expiring <3 days (criticales), Email semanal con items expiring <7 days (planificar)." + }, + { + "practice": "Optimización de Niveles de Stock", + "description": "Revisar y ajustar trimestralmente: Analizar consumo real: GET /inventory/analytics?metric=consumption&period=90d → consumption_daily_avg por ingrediente, Comparar con reorder_point actual: Si consumo aumentó: Subir reorder_point (evitar roturas de stock), Si consumo bajó: Bajar reorder_point (reducir capital inmovilizado). Optimizar reorder_quantity: Economic Order Quantity (EOQ) formula: EOQ = √[(2 × annual_demand × order_cost) / holding_cost_per_unit], Ejemplo: Harina demand anual: 18,000 kg, Order cost (logística, admin): 25€/pedido, Holding cost (almacenaje, capital): 0.50€/kg/año, EOQ = √[(2×18000×25) / 0.50] = √1,800,000 = 1,342 kg por pedido. Balancear vs supplier constraints: Si EOQ sugiere 1,342 kg pero proveedor vende en sacos de 25 kg → redondear a 1,350 kg (54 sacos), Si proveedor ofrece descuento por volumen >2,000 kg → evaluar trade-off (ahorro compra vs coste almacenaje extra). Resultado: Minimize total cost = purchase cost + order cost + holding cost." } ], - "metrics": [ - "Valor total de inventario (€)", - "Rotación de inventario (cuántas veces al año renuevas stock)", - "Desperdicio por caducidad (kg y € al mes)", - "Ingredientes con stock crítico" - ] + "troubleshooting": [ + { + "problem": "Discrepancias persistentes entre stock teórico y físico", + "solutions": [ + "Auditoría de movimientos: GET /stock/movements?ingredient_id=X&date_range=last_30d → revisar TODOS los movimientos, buscar: Movimientos sin reference_number (posibles errores manuales), Ajustes frecuentes del mismo user_id (posible error sistemático o fraude), Consumos que no matchean con production batches completados", + "Verificar proceso de registro: ¿Personal registra TODAS las salidas (production, waste, transfers)?, ¿Hay consumos ad-hoc no registrados (chef toma ingredientes para pruebas)?, Implementar: Control de acceso físico a almacén (solo personal autorizado), Registro obligatorio de salidas (formulario, incluso para pequeñas cantidades)", + "Investigar pérdidas naturales: Ingredientes con evaporación (líquidos en contenedores no herméticos), Merma por manipulación (bolsas rotas, derrames), Condensación/humedad (cambios de peso en harinas), Solución: Registrar allowance de merma normal (ej: 1-2% en harinas), Comparar discrepancia vs allowance", + "Automatizar cuando sea posible: Si discrepancias en producción → integrar balanzas digitales con sistema (auto-registro de consumos), Si discrepancias en recepción → escaneo de códigos de barras al recibir (evita errores de entrada manual)" + ] + }, + { + "problem": "Alertas de stock bajo muy frecuentes (rotura de stock continua)", + "solutions": [ + "Recalcular reorder_point: Fórmula actual: reorder_point = daily_usage × lead_time × 1.2, Revisar daily_usage: GET /analytics?metric=consumption&period=30d → usar promedio más reciente (últimos 30 días, no 90+ días si consumo cambió), Revisar lead_time: ¿Proveedor entrega realmente en 2 días o suele tardar 3-4? Usar lead_time_real máximo observado, Aumentar safety_factor de 1.2 a 1.3-1.5 si: Proveedor poco fiable (retrasos frecuentes), Consumo muy variable (CV >0.5), Ingrediente crítico sin sustituto", + "Optimizar reorder_quantity: Si haces pedidos pequeños → aumentar reorder_quantity para reducir frecuencia de pedidos, Negociar entregas más frecuentes con proveedor (semanal vs quincenal) → permite reducir reorder_point (menos stock de seguridad necesario)", + "Automatizar pedidos: Configurar auto-purchase orders para ingredientes críticos estables, Cuando stock < reorder_point → sistema crea PO automáticamente, Proveedor recibe email con pedido, Confirma delivery date, Manager solo aprueba (no crea manualmente)", + "Alertas tempranas: Configurar alert cuando stock = 1.5 × reorder_point (50% antes del punto crítico) → da más margen para actuar" + ] + }, + { + "problem": "Waste alto por caducidades (>10% del total)", + "solutions": [ + "Analizar causas raíz: GET /analytics/waste?waste_reason=expired&period=90d, Breakdown por ingrediente: ¿Pocos ingredientes causan mayoría del waste? (Pareto 80/20), Top offenders → investigar específicamente cada uno", + "Soluciones por causa: Compras excesivas: Reducir reorder_quantity (comprar más frecuente, cantidades menores), Shelf life corto: Negociar con proveedor entregas más frecuentes (semanal vs quincenal), Productos de lento movimiento reducen frescura de rápidos, Consumo irregular (picos y valles): Usar forecasting para ajustar compras a demanda real, Evitar compras 'por si acaso' sin forecast que lo justifique, FIFO físico no respetado: Auditar almacén físico (lotes antiguos escondidos atrás?), Re-entrenar personal en FIFO importance", + "Tácticas de reducción: Promociones pre-expiration: Productos que caducan en 3-5 días → vender con descuento (mejor margen reducido que waste 100%), Donar antes de caducar: Contactar bancos de alimentos (aceptan productos 1-2 días antes de expiration), Tax benefits + RSC + zero waste, Transformar productos: Ingredientes próximos a caducar → usar en productos con shelf life corto (pan del día, producción para consumo inmediato)", + "Monitoreo continuo: Weekly waste review meeting (15 min), Meta: Reducir waste_rate 1% cada mes hasta alcanzar <5% objetivo" + ] + } + ], + "advancedFeatures": [ + { + "feature": "AI-Powered Inventory Optimization (Roadmap)", + "description": "Machine Learning analizará patrones históricos para: Predecir consumo futuro por ingrediente (más preciso que promedios simples), Recomendar ajustes dinámicos de reorder_point según estacionalidad (verano vs invierno, festivos), Detectar anomalías (consumo inusual → posible theft, error de registro, nuevo patrón de demanda), Sugerir consolidación de proveedores (ingredientes similares de proveedores diferentes → negociar mejor precio con uno solo)." + }, + { + "feature": "IoT Sensors Integration", + "description": "Integración con sensores de almacén: Temperature & Humidity Sensors: Monitoreo 24/7 de cámaras frías, almacenes, Alertas automáticas si temperatura fuera de rango (food safety), Weight Sensors en estanterías: Tracking automático de stock level sin registro manual, Detección de discrepancias en tiempo real (stock_sensor vs stock_sistema), RFID Tags en lotes: Escaneo automático al mover lotes (recepción, uso, transferencia), Trazabilidad automática sin intervención humana. Status: En roadmap, pilotos con clientes enterprise." + }, + { + "feature": "Multi-Location Inventory (Enterprise)", + "description": "Para empresas con obrador central + sucursales: Stock unificado a nivel tenant con location dimension, Transfers automáticos: Obrador produce → auto-transfer a sucursales según forecast por sucursal, Dashboard consolidado: Stock total cross-location, Stock por location (drill-down), Transfer optimization: Sistema sugiere qué sucursal necesita reposición urgente, cuánto transferir desde obrador/otras sucursales. Disponible en plan Enterprise." + } + ], + "conclusion": "El sistema de inventario es la columna vertebral operativa de BakeWise. Invierte tiempo en: 1) Configuración inicial correcta (umbrales realistas de reorder_point basados en consumo real + lead time), 2) Disciplina de registro (TODAS las entradas y salidas, sin excepciones), 3) Inventario físico regular (trimestral mínimo, mensual ideal para ingredientes críticos), 4) Análisis continuo (revisar waste, discrepancias, niveles de stock semanalmente). Con inventario bien gestionado, lograrás: Waste <5% (vs 15-25% sin sistema), Roturas de stock <2% (vs 10-20% sin sistema), Capital inmovilizado -30% (stock óptimo, no excesivo ni insuficiente), Compliance HACCP 100% (trazabilidad completa para auditorías). El ROI típico del módulo de inventario es 300-500% en el primer año solo por reducción de waste." } }, "posIntegration": { diff --git a/frontend/src/pages/app/settings/subscription/SubscriptionPage.tsx b/frontend/src/pages/app/settings/subscription/SubscriptionPage.tsx index bb85ee49..b903fac7 100644 --- a/frontend/src/pages/app/settings/subscription/SubscriptionPage.tsx +++ b/frontend/src/pages/app/settings/subscription/SubscriptionPage.tsx @@ -1,5 +1,5 @@ import React, { useState, useEffect } from 'react'; -import { Crown, Users, MapPin, Package, TrendingUp, RefreshCw, AlertCircle, CheckCircle, ArrowRight, Star, ExternalLink, Download, CreditCard, X, Activity, Database, Zap, HardDrive, ShoppingCart, ChefHat, Settings, Sparkles, ChevronDown, ChevronUp } from 'lucide-react'; +import { Crown, Users, MapPin, Package, TrendingUp, RefreshCw, AlertCircle, CheckCircle, ArrowRight, Star, ExternalLink, Download, CreditCard, X, Activity, Database, Zap, HardDrive, ShoppingCart, ChefHat, Settings, Sparkles, ChevronDown, ChevronUp, AlertTriangle, Info, HelpCircle } from 'lucide-react'; import { Button, Card, Badge, Modal } from '../../../../components/ui'; import { DialogModal } from '../../../../components/ui/DialogModal/DialogModal'; import { PageHeader } from '../../../../components/layout'; @@ -20,7 +20,289 @@ import { import { useTranslation } from 'react-i18next'; import { useQueryClient } from '@tanstack/react-query'; -const SubscriptionPage: React.FC = () => { +// New simplified components for the redesign + +const SubscriptionStatusBanner: React.FC<{ + planName: string; + status: string; + nextBilling: string; + cost: string; + onUpgrade: () => void; + onManagePayment: () => void; + showUpgradeButton: boolean; + isTrial?: boolean; + trialEndsAt?: string; + trialDaysRemaining?: number; +}> = ({ planName, status, nextBilling, cost, onUpgrade, onManagePayment, showUpgradeButton, isTrial, trialEndsAt, trialDaysRemaining }) => { + + // Determine if we should show trial information + const showTrialInfo = isTrial && trialEndsAt; + + return ( + +
+
+

+ + {planName} {showTrialInfo && (Prueba Gratis)} +

+ + {showTrialInfo ? `En Prueba (${trialDaysRemaining} días)` : status === 'active' ? 'Activo' : status} + +
+ +
+

{showTrialInfo ? 'Fin de la Prueba' : 'Próxima Facturación'}

+

{showTrialInfo ? trialEndsAt : nextBilling}

+
+ +
+

Costo Mensual

+

+ {cost} {showTrialInfo && (después de la prueba)} +

+
+ +
+ {showTrialInfo ? ( + <> + + + + ) : ( + <> + + {showUpgradeButton && ( + + )} + + )} +
+
+ + {/* Trial Countdown Banner */} + {showTrialInfo && ( +
+
+
+ +
+
+

+ ¡Disfruta de tu periodo de prueba! Tu acceso gratuito termina en {trialDaysRemaining} días. +

+
+ +
+
+ )} +
+ ); +}; + +const UsageAlertCard: React.FC<{ + metric: string; + current: number; + limit: number | null; + percentage: number; + daysUntilLimit: number | null; + onUpgrade: () => void; + icon: React.ReactNode; +}> = ({ metric, current, limit, percentage, daysUntilLimit, onUpgrade, icon }) => { + const isUnlimited = limit === null || limit === -1; + const isCritical = percentage >= 90; + const isWarning = percentage >= 80 && percentage < 90; + + const getStatusColor = () => { + if (isCritical) return 'red'; + if (isWarning) return 'yellow'; + return 'green'; + }; + + const statusColor = getStatusColor(); + + const colorClasses = { + red: { + bg: 'bg-red-50 dark:bg-red-900/20', + border: 'border-red-200 dark:border-red-700', + text: 'text-red-600 dark:text-red-400', + icon: 'text-red-500' + }, + yellow: { + bg: 'bg-yellow-50 dark:bg-yellow-900/20', + border: 'border-yellow-200 dark:border-yellow-700', + text: 'text-yellow-600 dark:text-yellow-400', + icon: 'text-yellow-500' + }, + green: { + bg: 'bg-green-50 dark:bg-green-900/20', + border: 'border-green-200 dark:border-green-700', + text: 'text-green-600 dark:text-green-400', + icon: 'text-green-500' + } + }; + + const colors = colorClasses[statusColor]; + + return ( + +
+
+ {React.cloneElement(icon as React.ReactElement, { className: `w-5 h-5 ${colors.icon}` })} +
+ +
+
+

{metric}

+ {!isUnlimited && ( + + {Math.round(percentage)}% + + )} +
+ +
+ {current} + + / {isUnlimited ? '∞' : limit} + +
+ + {!isUnlimited && ( +
+
+
+ )} + + {isWarning && daysUntilLimit && ( +

+ {daysUntilLimit > 0 + ? `Alcanzarás el límite en ~${daysUntilLimit} días` + : 'Has alcanzado tu límite'} +

+ )} + + {isWarning && ( + + )} +
+
+ + ); +}; + +const SimpleMetricCard: React.FC<{ + icon: React.ReactNode; + label: string; + value: string; + status: 'safe' | 'warning' | 'critical'; +}> = ({ icon, label, value, status }) => { + const statusColors = { + safe: { + bg: 'bg-green-50 dark:bg-green-900/20', + border: 'border-green-200 dark:border-green-700', + text: 'text-green-600 dark:text-green-400' + }, + warning: { + bg: 'bg-yellow-50 dark:bg-yellow-900/20', + border: 'border-yellow-200 dark:border-yellow-700', + text: 'text-yellow-600 dark:text-yellow-400' + }, + critical: { + bg: 'bg-red-50 dark:bg-red-900/20', + border: 'border-red-200 dark:border-red-700', + text: 'text-red-600 dark:text-red-400' + } + }; + + const colors = statusColors[status]; + + return ( +
+
+
+ {icon} +
+
+

{label}

+

{value}

+
+ {status !== 'safe' && ( +
+ {status === 'warning' && } + {status === 'critical' && } +
+ )} +
+
+ ); +}; + +const CollapsibleSection: React.FC<{ + title: string; + icon: React.ReactNode; + isOpen: boolean; + onToggle: () => void; + children: React.ReactNode; + showAlert?: boolean; +}> = ({ title, icon, isOpen, onToggle, children, showAlert = false }) => { + return ( + + + + {isOpen && ( +
+ {children} +
+ )} +
+ ); +}; + +const SubscriptionPageRedesign: React.FC = () => { const user = useAuthUser(); const currentTenant = useCurrentTenant(); const { notifySubscriptionChanged } = useSubscriptionEvents(); @@ -38,10 +320,6 @@ const SubscriptionPage: React.FC = () => { const [invoices, setInvoices] = useState([]); const [invoicesLoading, setInvoicesLoading] = useState(false); const [invoicesLoaded, setInvoicesLoaded] = useState(false); - - // New state for enhanced features - const [showComparison, setShowComparison] = useState(false); - const [showROI, setShowROI] = useState(false); const [paymentMethodModalOpen, setPaymentMethodModalOpen] = useState(false); const [currentPaymentMethod, setCurrentPaymentMethod] = useState<{ brand?: string; @@ -50,15 +328,65 @@ const SubscriptionPage: React.FC = () => { exp_year?: number; } | null>(null); + // Section visibility states + const [showUsage, setShowUsage] = useState(false); + const [showBilling, setShowBilling] = useState(false); + const [showPlans, setShowPlans] = useState(false); + const [showComparison, setShowComparison] = useState(false); + const [showROI, setShowROI] = useState(false); + // Use new subscription hook for usage forecast data const { subscription: subscriptionData, usage: forecastUsage, forecast } = useSubscription(); + // Auto-expand sections with alerts + useEffect(() => { + if (forecastUsage && forecastUsage.highUsageMetrics && forecastUsage.highUsageMetrics.length > 0) { + setShowUsage(true); + } + }, [forecastUsage]); + // Load subscription data on component mount React.useEffect(() => { loadSubscriptionData(); loadInvoices(); + loadCurrentPaymentMethod(); }, []); + // Add function to load current payment method + const loadCurrentPaymentMethod = async () => { + const tenantId = currentTenant?.id || user?.tenant_id; + + if (!tenantId) { + return; + } + + try { + // Try to get payment method from usage summary first + // If not available, we'll need to add an API endpoint + // For now, we'll use a fallback approach + + // Check if we have payment method info in the usage summary + // This is a temporary solution until we add proper API support + if (usageSummary && (usageSummary as any).payment_method) { + setCurrentPaymentMethod((usageSummary as any).payment_method); + } else { + // Fallback: Try to fetch from a potential endpoint + // This will fail gracefully if endpoint doesn't exist + try { + const paymentMethod = await subscriptionService.getCurrentPaymentMethod(tenantId); + if (paymentMethod) { + setCurrentPaymentMethod(paymentMethod); + } + } catch (error) { + console.log('Payment method endpoint not available, using fallback'); + // If no payment method is available, we'll show the "Add Payment Method" flow + } + } + } catch (error) { + console.error('Error loading payment method:', error); + } + }; + // Track page view useEffect(() => { if (usageSummary) { @@ -98,7 +426,6 @@ const SubscriptionPage: React.FC = () => { subscriptionService.fetchAvailablePlans() ]); - // CRITICAL: No more mock data - show real errors instead if (!usage || !usage.usage) { throw new Error('No subscription found. Please contact support or create a new subscription.'); } @@ -149,52 +476,18 @@ const SubscriptionPage: React.FC = () => { const result = await subscriptionService.upgradePlan(tenantId, selectedPlan); if (result.success) { - console.log('✅ Subscription upgraded successfully:', { - oldPlan: result.old_plan, - newPlan: result.new_plan, - requiresTokenRefresh: result.requires_token_refresh - }); - showToast.success(result.message); - - // Invalidate cache to ensure fresh data on next fetch subscriptionService.invalidateCache(); - // CRITICAL: Force immediate token refresh to get updated JWT with new subscription tier - // This ensures menus, access controls, and UI reflect the new plan instantly - // The backend has already invalidated old tokens via subscription_changed_at timestamp if (result.requires_token_refresh) { - try { - console.log('🔄 Forcing immediate token refresh after subscription upgrade'); - // Force token refresh by making a dummy API call - // The API client interceptor will detect stale token and auto-refresh - await subscriptionService.getUsageSummary(tenantId).catch(() => { - // Ignore errors - we just want to trigger the refresh - console.log('Token refresh triggered via usage summary call'); - }); - console.log('✅ Token refreshed - new subscription tier now active in JWT'); - } catch (error) { - console.warn('Token refresh trigger failed, but subscription is still upgraded:', error); - } + await subscriptionService.getUsageSummary(tenantId).catch(() => {}); } - // CRITICAL: Invalidate ALL subscription-related React Query caches - // This forces all components using subscription data to refetch - console.log('🔄 Invalidating React Query caches for subscription data...'); await queryClient.invalidateQueries({ queryKey: ['subscription-usage'] }); await queryClient.invalidateQueries({ queryKey: ['tenant'] }); - console.log('✅ React Query caches invalidated'); - - // Broadcast subscription change event to refresh sidebar and other components - // This increments subscriptionVersion which triggers React Query refetch - console.log('📢 Broadcasting subscription change event...'); notifySubscriptionChanged(); - console.log('✅ Subscription change event broadcasted'); - - // Reload subscription data to show new plan immediately await loadSubscriptionData(); - // Close dialog and clear selection immediately for seamless UX setUpgradeDialogOpen(false); setSelectedPlan(''); } else { @@ -262,7 +555,6 @@ const SubscriptionPage: React.FC = () => { setInvoicesLoaded(true); } catch (error) { console.error('Error loading invoices:', error); - // Don't show error toast on initial load, just log it if (invoicesLoaded) { showToast.error('Error al cargar las facturas'); } @@ -281,40 +573,45 @@ const SubscriptionPage: React.FC = () => { } }; - const ProgressBar: React.FC<{ value: number; className?: string }> = ({ value, className = '' }) => { - const getProgressColor = () => { - if (value >= 90) return 'bg-red-500'; - if (value >= 80) return 'bg-yellow-500'; - return 'bg-green-500'; - }; - - return ( -
-
-
-
-
- ); + // Helper function to determine metric status + const getMetricStatus = (usagePercentage: number): 'safe' | 'warning' | 'critical' => { + if (usagePercentage >= 90) return 'critical'; + if (usagePercentage >= 80) return 'warning'; + return 'safe'; }; - return ( -
- + // Helper function to format limit display + const formatLimitDisplay = (current: number, limit: number | null | undefined, unlimitedText: string): string => { + if (limit === null || limit === -1 || limit === undefined) { + return `${current} (${unlimitedText})`; + } + return `${current}/${limit}`; + }; - {subscriptionLoading ? ( + if (subscriptionLoading) { + return ( +
+

Cargando información de suscripción...

- ) : !usageSummary || !availablePlans ? ( +
+ ); + } + + if (!usageSummary || !availablePlans) { + return ( +
+
@@ -328,509 +625,276 @@ const SubscriptionPage: React.FC = () => {
- ) : ( - <> - {/* Current Plan Overview */} - -
-

- - Plan Actual +

+ ); + } + + // Check if there are any high usage metrics + const hasHighUsageMetrics = forecastUsage && forecastUsage.highUsageMetrics && forecastUsage.highUsageMetrics.length > 0; + + return ( +
+ + + {/* NEW: Subscription Status Banner - Always Visible */} + setShowPlans(true)} + onManagePayment={() => setPaymentMethodModalOpen(true)} + showUpgradeButton={usageSummary.plan !== 'enterprise'} + isTrial={usageSummary.status === 'trialing'} + trialEndsAt={usageSummary.trial_ends_at ? new Date(usageSummary.trial_ends_at).toLocaleDateString('es-ES', { + day: '2-digit', + month: '2-digit', + year: 'numeric' + }) : undefined} + trialDaysRemaining={usageSummary.trial_ends_at ? Math.max(0, Math.ceil((new Date(usageSummary.trial_ends_at).getTime() - new Date().getTime()) / (1000 * 60 * 60 * 24))) : undefined} + /> + + {/* NEW: Critical Alerts Section - Only shown when needed */} + {hasHighUsageMetrics && ( + +
+
+ +
+
+

+ ¡Atención! Límite de uso cercano

- - {usageSummary.status === 'active' ? 'Activo' : usageSummary.status} - -
- -
-
-
- Plan - {usageSummary.plan} -
-
-
-
- Precio Mensual - {subscriptionService.formatPrice(usageSummary.monthly_price)} -
-
-
-
- Próxima Facturación - - {new Date(usageSummary.next_billing_date).toLocaleDateString('es-ES', { day: '2-digit', month: '2-digit', year: 'numeric' })} - -
-
-
-
- Ciclo de Facturación - - {usageSummary.billing_cycle === 'monthly' ? 'Mensual' : 'Anual'} - -
+

+ Estás acercándote a los límites de tu plan actual. Considera actualizar para evitar interrupciones. +

+
+ +
- +
+ + )} - {/* Usage Details */} - -

- - Uso de Recursos -

- - {/* Team & Organization Metrics */} -
-

Equipo & Organización

-
- {/* Users */} -
-
-
-
- -
- Usuarios -
- - {usageSummary.usage.users.current}/ - {usageSummary.usage.users.unlimited ? '∞' : usageSummary.usage.users.limit ?? 0} - -
- -

- {usageSummary.usage.users.usage_percentage}% utilizado - {usageSummary.usage.users.unlimited ? 'Ilimitado' : `${(usageSummary.usage.users.limit ?? 0) - usageSummary.usage.users.current} restantes`} -

-
- - {/* Locations */} -
-
-
-
- -
- Ubicaciones -
- - {usageSummary.usage.locations.current}/ - {usageSummary.usage.locations.unlimited ? '∞' : usageSummary.usage.locations.limit ?? 0} - -
- -

- {usageSummary.usage.locations.usage_percentage}% utilizado - {usageSummary.usage.locations.unlimited ? 'Ilimitado' : `${(usageSummary.usage.locations.limit ?? 0) - usageSummary.usage.locations.current} restantes`} -

-
-
+ {/* NEW: Collapsible Usage Section */} + } + isOpen={showUsage} + onToggle={() => setShowUsage(!showUsage)} + showAlert={hasHighUsageMetrics} + > +
+ {/* Team & Organization */} +
+

+ + Equipo & Organización +

+
+ } + label="Usuarios Activos" + value={formatLimitDisplay( + usageSummary.usage.users.current, + usageSummary.usage.users.limit, + "Ilimitado" + )} + status={getMetricStatus(usageSummary.usage.users.usage_percentage)} + /> + } + label="Ubicaciones" + value={formatLimitDisplay( + usageSummary.usage.locations.current, + usageSummary.usage.locations.limit, + "Ilimitado" + )} + status={getMetricStatus(usageSummary.usage.locations.usage_percentage)} + />
+
- {/* Product & Inventory Metrics */} -
-

Productos & Inventario

-
- {/* Products */} -
-
-
-
- -
- Productos -
- - {usageSummary.usage.products.current}/ - {usageSummary.usage.products.unlimited ? '∞' : usageSummary.usage.products.limit ?? 0} - -
- -

- {usageSummary.usage.products.usage_percentage}% utilizado - {usageSummary.usage.products.unlimited ? 'Ilimitado' : `${(usageSummary.usage.products.limit ?? 0) - usageSummary.usage.products.current} restantes`} -

-
- - {/* Recipes */} -
-
-
-
- -
- Recetas -
- - {usageSummary.usage.recipes.current}/ - {usageSummary.usage.recipes.unlimited ? '∞' : usageSummary.usage.recipes.limit ?? 0} - -
- -

- {usageSummary.usage.recipes.usage_percentage}% utilizado - {usageSummary.usage.recipes.unlimited ? 'Ilimitado' : `${(usageSummary.usage.recipes.limit ?? 0) - usageSummary.usage.recipes.current} restantes`} -

-
- - {/* Suppliers */} -
-
-
-
- -
- Proveedores -
- - {usageSummary.usage.suppliers.current}/ - {usageSummary.usage.suppliers.unlimited ? '∞' : usageSummary.usage.suppliers.limit ?? 0} - -
- -

- {usageSummary.usage.suppliers.usage_percentage}% utilizado - {usageSummary.usage.suppliers.unlimited ? 'Ilimitado' : `${(usageSummary.usage.suppliers.limit ?? 0) - usageSummary.usage.suppliers.current} restantes`} -

-
-
+ {/* Products & Inventory */} +
+

+ + Productos & Inventario +

+
+ } + label="Productos" + value={formatLimitDisplay( + usageSummary.usage.products.current, + usageSummary.usage.products.limit, + "Ilimitado" + )} + status={getMetricStatus(usageSummary.usage.products.usage_percentage)} + /> + } + label="Recetas" + value={formatLimitDisplay( + usageSummary.usage.recipes.current, + usageSummary.usage.recipes.limit, + "Ilimitado" + )} + status={getMetricStatus(usageSummary.usage.recipes.usage_percentage)} + /> + } + label="Proveedores" + value={formatLimitDisplay( + usageSummary.usage.suppliers.current, + usageSummary.usage.suppliers.limit, + "Ilimitado" + )} + status={getMetricStatus(usageSummary.usage.suppliers.usage_percentage)} + />
+
- {/* ML & Analytics Metrics (Daily) */} -
-

IA & Analíticas (Uso Diario)

+ {/* AI & Analytics */} +
+

+ + IA & Analíticas +

+
+ } + label="Predicciones de Demanda Hoy" + value={formatLimitDisplay( + usageSummary.usage.training_jobs_today.current, + usageSummary.usage.training_jobs_today.limit, + "Ilimitado" + )} + status={getMetricStatus(usageSummary.usage.training_jobs_today.usage_percentage)} + /> + } + label="Reportes de Ventas Hoy" + value={formatLimitDisplay( + usageSummary.usage.forecasts_today.current, + usageSummary.usage.forecasts_today.limit, + "Ilimitado" + )} + status={getMetricStatus(usageSummary.usage.forecasts_today.usage_percentage)} + /> +
+
+ + {/* API & Storage */} +
+

+ + API & Almacenamiento +

+
+ } + label="Llamadas API (Esta Hora)" + value={formatLimitDisplay( + usageSummary.usage.api_calls_this_hour.current, + usageSummary.usage.api_calls_this_hour.limit, + "Ilimitado" + )} + status={getMetricStatus(usageSummary.usage.api_calls_this_hour.usage_percentage)} + /> + } + label="Almacenamiento" + value={`${usageSummary.usage.file_storage_used_gb.current.toFixed(2)} GB / ${usageSummary.usage.file_storage_used_gb.limit} GB`} + status={getMetricStatus(usageSummary.usage.file_storage_used_gb.usage_percentage)} + /> +
+
+ + {/* Detailed Usage Alerts - Only for high usage metrics */} + {hasHighUsageMetrics && forecastUsage && ( +
+

+ + Alertas de Uso +

- {/* Training Jobs Today */} -
-
-
-
- -
- Entrenamientos IA Hoy -
- - {usageSummary.usage.training_jobs_today.current}/ - {usageSummary.usage.training_jobs_today.unlimited ? '∞' : usageSummary.usage.training_jobs_today.limit ?? 0} - -
- -

- {usageSummary.usage.training_jobs_today.usage_percentage}% utilizado - {usageSummary.usage.training_jobs_today.unlimited ? 'Ilimitado' : `${(usageSummary.usage.training_jobs_today.limit ?? 0) - usageSummary.usage.training_jobs_today.current} restantes`} -

-
- - {/* Forecasts Today */} -
-
-
-
- -
- Pronósticos Hoy -
- - {usageSummary.usage.forecasts_today.current}/ - {usageSummary.usage.forecasts_today.unlimited ? '∞' : usageSummary.usage.forecasts_today.limit ?? 0} - -
- -

- {usageSummary.usage.forecasts_today.usage_percentage}% utilizado - {usageSummary.usage.forecasts_today.unlimited ? 'Ilimitado' : `${(usageSummary.usage.forecasts_today.limit ?? 0) - usageSummary.usage.forecasts_today.current} restantes`} -

-
+ {forecastUsage.highUsageMetrics.map((metric: any) => ( + handleUpgradeClick('professional', 'usage_alert')} + icon={metric.icon || } + /> + ))}
+ )} +
+ - {/* API & Storage Metrics */} -
-

API & Almacenamiento

-
- {/* API Calls This Hour */} -
-
-
-
- -
- Llamadas API (Esta Hora) -
- - {usageSummary.usage.api_calls_this_hour.current}/ - {usageSummary.usage.api_calls_this_hour.unlimited ? '∞' : usageSummary.usage.api_calls_this_hour.limit ?? 0} - -
- -

- {usageSummary.usage.api_calls_this_hour.usage_percentage}% utilizado - {usageSummary.usage.api_calls_this_hour.unlimited ? 'Ilimitado' : `${(usageSummary.usage.api_calls_this_hour.limit ?? 0) - usageSummary.usage.api_calls_this_hour.current} restantes`} -

-
- - {/* File Storage */} -
-
-
-
- -
- Almacenamiento -
- - {usageSummary.usage.file_storage_used_gb.current.toFixed(2)}/ - {usageSummary.usage.file_storage_used_gb.unlimited ? '∞' : `${usageSummary.usage.file_storage_used_gb.limit} GB`} - -
- -

- {usageSummary.usage.file_storage_used_gb.usage_percentage}% utilizado - {usageSummary.usage.file_storage_used_gb.unlimited ? 'Ilimitado' : `${((usageSummary.usage.file_storage_used_gb.limit ?? 0) - usageSummary.usage.file_storage_used_gb.current).toFixed(2)} GB restantes`} -

-
+ {/* NEW: Collapsible Billing Section */} + } + isOpen={showBilling} + onToggle={() => setShowBilling(!showBilling)} + > +
+ {/* Payment Method */} +
+
+
+
-
- - - {/* Enhanced Usage Metrics with Predictive Analytics */} - {forecastUsage && forecast && ( - -
-

- - Análisis Predictivo de Uso -

-

- Predicciones basadas en tendencias de crecimiento +

+

Método de Pago

+

+ {currentPaymentMethod + ? `Tarjeta terminada en ${currentPaymentMethod.last4} (${currentPaymentMethod.brand})` + : 'No hay método de pago configurado'}

-
- -
- {/* Products */} - handleUpgradeClick('professional', 'usage_metric_products')} - icon={} - /> - - {/* Users */} - handleUpgradeClick('professional', 'usage_metric_users')} - icon={} - /> - - {/* Locations */} - handleUpgradeClick('professional', 'usage_metric_locations')} - icon={} - /> - - {/* Training Jobs */} - handleUpgradeClick('professional', 'usage_metric_training')} - icon={} - /> - - {/* Forecasts */} - handleUpgradeClick('professional', 'usage_metric_forecasts')} - icon={} - /> - - {/* Storage */} - handleUpgradeClick('professional', 'usage_metric_storage')} - icon={} - /> -
- - )} - - {/* High Usage Warning Banner (Starter tier with >80% usage) */} - {usageSummary.plan === 'starter' && forecastUsage && forecastUsage.highUsageMetrics.length > 0 && ( - -
-
- -
-
-

- ¡Estás superando el plan Starter! -

-

- Estás usando {forecastUsage.highUsageMetrics.length} métrica{forecastUsage.highUsageMetrics.length > 1 ? 's' : ''} con más del 80% de capacidad. - Actualiza a Professional para obtener 10 veces más capacidad y funciones avanzadas. -

-
- - -
-
-
-
- )} - - {/* ROI Calculator (Starter tier only) */} - {usageSummary.plan === 'starter' && ( - -
-

Calcula Tus Ahorros

- + + {currentPaymentMethod ? 'Actualizar Método de Pago' : 'Agregar Método de Pago'} +
- - {showROI && ( - handleUpgradeClick('professional', 'roi_calculator')} - /> - )} -
- )} - - {/* Plan Comparison */} - {availablePlans && ( - -
-

Comparar Planes

- -
- - {showComparison && ( - handleUpgradeClick(tier, 'comparison_table')} - mode="inline" - /> - )} -
- )} - - {/* Available Plans */} - -

- - Planes Disponibles -

- handleUpgradeClick(plan, 'pricing_cards')} - showPilotBanner={false} - /> -
- - {/* Invoices Section */} - -
-

- - Historial de Facturas -

+
+ + {/* Invoices */} +
+

+ Historial de Facturas +

{invoicesLoading && !invoicesLoaded ? (
@@ -862,7 +926,7 @@ const SubscriptionPage: React.FC = () => { - {invoices.map((invoice) => ( + {invoices.slice(0, 5).map((invoice) => ( {new Date(invoice.date).toLocaleDateString('es-ES', { @@ -898,85 +962,156 @@ const SubscriptionPage: React.FC = () => { ))} + {invoices.length > 5 && ( +
+ +
+ )}
)} - +
- {/* Subscription Management */} - -

- - Gestión de Suscripción -

- -
- {/* Payment Method Card */} -
-
-
- -
-
-

Método de Pago

-

- Actualiza tu información de pago para asegurar la continuidad de tu servicio sin interrupciones. -

- -
-
-
- - {/* Cancel Subscription Card */} -
-
-
- -
-
-

Cancelar Suscripción

-

- Si cancelas, mantendrás acceso de solo lectura hasta el final de tu período de facturación actual. -

- -
-
+ {/* Support Contact */} +
+
+ +
+

+ ¿Preguntas sobre tu factura? +

+

+ Contacta a nuestro equipo de soporte en{' '} + + support@bakery-ia.com + +

+
+
+ - {/* Additional Info */} -
-
- -
-

- ¿Necesitas ayuda? -

-

- Si tienes preguntas sobre tu suscripción o necesitas asistencia, contacta a nuestro equipo de soporte en{' '} - - support@bakery-ia.com - -

-
+ {/* NEW: Collapsible Plans Section */} + } + isOpen={showPlans} + onToggle={() => setShowPlans(!showPlans)} + > +
+ {/* Available Plans */} +
+

+ Planes Disponibles +

+ handleUpgradeClick(plan, 'pricing_cards')} + showPilotBanner={false} + /> +
+ + {/* Plan Comparison */} + {availablePlans && ( +
+
+

+ Comparación Detallada +

+ +
+ + {showComparison && ( + handleUpgradeClick(tier, 'comparison_table')} + mode="inline" + /> + )} +
+ )} + + {/* ROI Calculator for Starter tier */} + {usageSummary.plan === 'starter' && ( +
+
+

+ Calcula Tus Ahorros +

+ +
+ + {showROI && ( + handleUpgradeClick('professional', 'roi_calculator')} + /> + )} +
+ )} + + {/* Cancel Subscription */} +
+
+
+ +
+
+

Cancelar Suscripción

+

+ Si cancelas, mantendrás acceso de solo lectura hasta el final de tu período de facturación actual. +

+
- - - )} +
+
+
{/* Payment Method Update Modal */} { ); }; -export default SubscriptionPage; +export default SubscriptionPageRedesign; \ No newline at end of file diff --git a/frontend/src/pages/public/RegisterCompletePage.tsx b/frontend/src/pages/public/RegisterCompletePage.tsx new file mode 100644 index 00000000..19625075 --- /dev/null +++ b/frontend/src/pages/public/RegisterCompletePage.tsx @@ -0,0 +1,92 @@ +import React, { useEffect } from 'react'; +import { useTranslation } from 'react-i18next'; +import { useNavigate, useSearchParams } from 'react-router-dom'; +import { Card, Button } from '../../components/ui'; +import { CheckCircle, AlertCircle } from 'lucide-react'; +import { showToast } from '../../utils/toast'; + +export const RegisterCompletePage: React.FC = () => { + const { t } = useTranslation(); + const navigate = useNavigate(); + const [searchParams] = useSearchParams(); + + useEffect(() => { + // Check for Stripe redirect parameters + const setupIntent = searchParams.get('setup_intent'); + const redirectStatus = searchParams.get('redirect_status'); + const setupIntentClientSecret = searchParams.get('setup_intent_client_secret'); + + if (redirectStatus === 'succeeded' && setupIntent) { + // Successful 3DS authentication + showToast.success(t('auth:register.3ds_success', 'Autenticación 3D Secure completada con éxito'), { + title: t('auth:alerts.success', 'Éxito') + }); + + // Redirect to login page after successful authentication + setTimeout(() => { + navigate('/login', { + state: { + from3DS: true, + message: t('auth:register.3ds_complete', 'Tu tarjeta ha sido verificada. Por favor, inicia sesión.') + } + }); + }, 3000); + } else if (redirectStatus === 'failed') { + // Failed 3DS authentication + showToast.error(t('auth:register.3ds_failed', 'La autenticación 3D Secure ha fallado'), { + title: t('auth:alerts.authentication_error', 'Error de autenticación') + }); + + // Redirect to registration page to try again + setTimeout(() => { + navigate('/register', { + state: { + error: t('auth:register.3ds_failed_try_again', 'La autenticación 3D Secure ha fallado. Por favor, intenta de nuevo con otra tarjeta.') + } + }); + }, 3000); + } + }, [searchParams, navigate, t]); + + return ( +
+ +
+ {searchParams.get('redirect_status') === 'succeeded' ? ( + + ) : ( + + )} +
+ +

+ {searchParams.get('redirect_status') === 'succeeded' + ? t('auth:register.processing_3ds', 'Procesando autenticación') + : t('auth:register.3ds_error', 'Error de autenticación')} +

+ +

+ {searchParams.get('redirect_status') === 'succeeded' + ? t('auth:register.3ds_redirect_message', 'Tu autenticación 3D Secure se ha completado. Serás redirigido en breve...') + : t('auth:register.3ds_failed_message', 'Hubo un problema con la autenticación 3D Secure. Serás redirigido para intentar de nuevo...')} +

+ + + + +
+
+ ); +}; \ No newline at end of file diff --git a/frontend/src/stores/auth.store.ts b/frontend/src/stores/auth.store.ts index 72e602e9..aa5c949d 100644 --- a/frontend/src/stores/auth.store.ts +++ b/frontend/src/stores/auth.store.ts @@ -188,6 +188,39 @@ export const useAuthStore = create()( const response = await authService.registerWithSubscription(userData); + // NEW ARCHITECTURE: Check if SetupIntent verification is required + if (response && response.requires_action) { + // SetupIntent required - NO user created yet, NO tokens returned + // Store registration data for post-3DS completion + const pendingRegistrationData = { + email: userData.email, + password: userData.password, + full_name: userData.full_name, + setup_intent_id: response.setup_intent_id, + plan_id: response.plan_id, + payment_method_id: response.payment_method_id, + billing_interval: response.billing_interval, + coupon_code: response.coupon_code, + customer_id: response.customer_id, + payment_customer_id: response.payment_customer_id, + trial_period_days: response.trial_period_days, + client_secret: response.client_secret + }; + + // Store in session storage for post-3DS completion + sessionStorage.setItem('pending_registration_data', JSON.stringify(pendingRegistrationData)); + + set({ + isLoading: false, + error: null, + pendingRegistrationData, + }); + + // Return the SetupIntent data for frontend to handle 3DS + return response; + } + + // OLD FLOW: No SetupIntent required - user created and authenticated if (response && response.access_token) { // Set the auth tokens on the API client immediately apiClient.setAuthToken(response.access_token); @@ -223,6 +256,60 @@ export const useAuthStore = create()( } }, + completeRegistrationAfterSetupIntent: async (completionData: { + email: string; + password: string; + full_name: string; + setup_intent_id: string; + plan_id: string; + payment_method_id: string; + billing_interval: 'monthly' | 'yearly'; + coupon_code?: string; + }) => { + try { + set({ isLoading: true, error: null }); + + const response = await authService.completeRegistrationAfterSetupIntent(completionData); + + if (response && response.access_token) { + // Set the auth tokens on the API client immediately + apiClient.setAuthToken(response.access_token); + if (response.refresh_token) { + apiClient.setRefreshToken(response.refresh_token); + } + + // Store subscription ID in state for onboarding flow + const pendingSubscriptionId = response.subscription_id || null; + + // Clear pending registration data from session storage + sessionStorage.removeItem('pending_registration_data'); + + set({ + user: response.user || null, + token: response.access_token, + refreshToken: response.refresh_token || null, + isAuthenticated: true, + isLoading: false, + error: null, + pendingSubscriptionId, + pendingRegistrationData: null, + }); + } else { + throw new Error('Registration completion failed'); + } + } catch (error) { + set({ + user: null, + token: null, + refreshToken: null, + isAuthenticated: false, + isLoading: false, + error: error instanceof Error ? error.message : 'Error completando el registro', + }); + throw error; + } + }, + logout: () => { // Clear the auth tokens from API client apiClient.setAuthToken(null); @@ -446,6 +533,7 @@ export const useAuthActions = () => useAuthStore((state) => ({ login: state.login, register: state.register, registerWithSubscription: state.registerWithSubscription, + completeRegistrationAfterSetupIntent: state.completeRegistrationAfterSetupIntent, logout: state.logout, refreshAuth: state.refreshAuth, updateUser: state.updateUser, diff --git a/gateway/app/routes/subscription.py b/gateway/app/routes/subscription.py index 1276b92a..0b9b0812 100644 --- a/gateway/app/routes/subscription.py +++ b/gateway/app/routes/subscription.py @@ -48,6 +48,12 @@ async def proxy_subscription_status(request: Request, tenant_id: str = Path(...) target_path = f"/api/v1/subscriptions/{tenant_id}/status" return await _proxy_to_tenant_service(request, target_path) +@router.api_route("/subscriptions/{tenant_id}/payment-method", methods=["GET", "OPTIONS"]) +async def proxy_payment_method(request: Request, tenant_id: str = Path(...)): + """Proxy payment method request to tenant service""" + target_path = f"/api/v1/subscriptions/{tenant_id}/payment-method" + return await _proxy_to_tenant_service(request, target_path) + @router.api_route("/subscriptions/cancel", methods=["POST", "OPTIONS"]) async def proxy_subscription_cancel(request: Request): """Proxy subscription cancellation request to tenant service""" @@ -66,6 +72,12 @@ async def proxy_payment_customer_create(request: Request): target_path = "/api/v1/payment-customers/create" return await _proxy_to_tenant_service(request, target_path) +@router.api_route("/setup-intents/{setup_intent_id}/verify", methods=["GET", "OPTIONS"]) +async def proxy_setup_intent_verify(request: Request, setup_intent_id: str): + """Proxy SetupIntent verification request to tenant service""" + target_path = f"/api/v1/setup-intents/{setup_intent_id}/verify" + return await _proxy_to_tenant_service(request, target_path) + @router.api_route("/subscriptions/reactivate", methods=["POST", "OPTIONS"]) async def proxy_subscription_reactivate(request: Request): """Proxy subscription reactivation request to tenant service""" @@ -116,10 +128,13 @@ async def _proxy_request(request: Request, target_path: str, service_url: str): # Debug logging user_context = getattr(request.state, 'user', None) + service_context = getattr(request.state, 'service', None) if user_context: logger.info(f"Forwarding subscription request to {url} with user context: user_id={user_context.get('user_id')}, email={user_context.get('email')}, subscription_tier={user_context.get('subscription_tier', 'not_set')}") + elif service_context: + logger.debug(f"Forwarding subscription request to {url} with service context: service_name={service_context.get('service_name')}, user_type=service") else: - logger.warning(f"No user context available when forwarding subscription request to {url}") + logger.warning(f"No user or service context available when forwarding subscription request to {url}") # Get request body if present body = None diff --git a/services/auth/app/api/auth_operations.py b/services/auth/app/api/auth_operations.py index cdb56497..865b5de9 100644 --- a/services/auth/app/api/auth_operations.py +++ b/services/auth/app/api/auth_operations.py @@ -13,6 +13,20 @@ from app.schemas.auth import ( UserRegistration, UserLogin, TokenResponse, RefreshTokenRequest, PasswordChange, PasswordReset, UserResponse ) +from pydantic import BaseModel +from typing import Optional + + +# Schema for SetupIntent completion data +class SetupIntentCompletionData(BaseModel): + email: str + password: str + full_name: str + setup_intent_id: str + plan_id: str + payment_method_id: str + billing_interval: str = "monthly" + coupon_code: Optional[str] = None from app.services.auth_service import EnhancedAuthService from app.models.users import User from app.core.database import get_db @@ -102,8 +116,7 @@ async def register( detail="Registration failed" ) -@router.post("/api/v1/auth/register-with-subscription", response_model=TokenResponse) -@track_execution_time("enhanced_registration_with_subscription_duration_seconds", "auth-service") +@router.post("/api/v1/auth/register-with-subscription") async def register_with_subscription( user_data: UserRegistration, request: Request, @@ -112,18 +125,20 @@ async def register_with_subscription( """ Register new user and create subscription in one call - This endpoint implements the new registration flow where: - 1. User is created - 2. Payment customer is created via tenant service - 3. Tenant-independent subscription is created via tenant service - 4. Subscription data is stored in onboarding progress - 5. User is authenticated and returned with tokens + NEW ARCHITECTURE: User is ONLY created AFTER payment verification + + Flow: + 1. Validate user data + 2. Create payment customer via tenant service + 3. Create SetupIntent via tenant service + 4. If SetupIntent requires_action: Return SetupIntent data WITHOUT creating user + 5. If no SetupIntent required: Create user, create subscription, return tokens The subscription will be linked to a tenant during the onboarding flow. """ metrics = get_metrics_collector(request) - logger.info("Registration with subscription attempt using new architecture", + logger.info("Registration with subscription attempt using secure architecture", email=user_data.email) try: @@ -146,54 +161,135 @@ async def register_with_subscription( detail="Full name is required" ) - # Step 1: Register user using enhanced service - logger.info("Step 1: Creating user", email=user_data.email) + # NEW ARCHITECTURE: Create payment customer and SetupIntent BEFORE user creation + if user_data.subscription_plan and user_data.payment_method_id: + logger.info("Step 1: Creating payment customer and SetupIntent BEFORE user creation", + email=user_data.email, + plan=user_data.subscription_plan) + + # Use tenant service orchestration endpoint for payment setup + # This creates payment customer and SetupIntent in one coordinated workflow + payment_setup_result = await auth_service.create_registration_payment_setup_via_tenant_service( + user_data=user_data + ) + + if not payment_setup_result or not payment_setup_result.get('success'): + logger.error("Payment setup failed", + email=user_data.email, + error="Payment setup returned no success") + + if metrics: + metrics.increment_counter("enhanced_registration_with_subscription_total", + labels={"status": "failed_payment_setup"}) + + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Payment setup failed" + ) + + # CRITICAL: Check if SetupIntent requires 3DS authentication + if payment_setup_result.get('requires_action'): + # NEW ARCHITECTURE: Return SetupIntent data WITHOUT creating user + logger.info("Payment setup requires SetupIntent authentication - deferring user creation", + email=user_data.email, + action_type=payment_setup_result.get('action_type'), + setup_intent_id=payment_setup_result.get('setup_intent_id')) + + if metrics: + metrics.increment_counter("enhanced_registration_with_subscription_total", + labels={"status": "requires_3ds"}) + + # Return SetupIntent data for frontend to handle 3DS + # NO user created yet, NO tokens returned + return { + "requires_action": True, + "action_type": payment_setup_result.get('action_type'), + "client_secret": payment_setup_result.get('client_secret'), + "setup_intent_id": payment_setup_result.get('setup_intent_id'), + "customer_id": payment_setup_result.get('customer_id'), + "payment_customer_id": payment_setup_result.get('payment_customer_id'), + "plan_id": payment_setup_result.get('plan_id'), + "payment_method_id": payment_setup_result.get('payment_method_id'), + "trial_period_days": payment_setup_result.get('trial_period_days'), + "email": payment_setup_result.get('email'), + "full_name": payment_setup_result.get('full_name'), + "billing_interval": payment_setup_result.get('billing_interval'), + "coupon_code": payment_setup_result.get('coupon_code'), + "message": payment_setup_result.get('message') or "Payment verification required before account creation" + } + else: + # No 3DS required - proceed with user creation + logger.info("No SetupIntent required - proceeding with user creation", + email=user_data.email) + else: + # No subscription data provided - proceed with user creation + logger.info("No subscription data provided - proceeding with user creation", + email=user_data.email) + + # Step 2: Create user (ONLY if no SetupIntent required) + logger.info("Step 2: Creating user after payment verification", + email=user_data.email) result = await auth_service.register_user(user_data) user_id = result.user.id - logger.info("User created successfully", user_id=user_id) + logger.info("User created successfully", + user_id=user_id, + email=user_data.email) - # Step 2: Create subscription via tenant service (if subscription data provided) - subscription_id = None + # Step 3: If subscription was created (no 3DS), store in onboarding progress if user_data.subscription_plan and user_data.payment_method_id: - logger.info("Step 2: Creating tenant-independent subscription", - user_id=user_id, - plan=user_data.subscription_plan) - - subscription_result = await auth_service.create_subscription_via_tenant_service( - user_id=user_id, - plan_id=user_data.subscription_plan, - payment_method_id=user_data.payment_method_id, - billing_cycle=user_data.billing_cycle or "monthly", - coupon_code=user_data.coupon_code - ) - - if subscription_result: - subscription_id = subscription_result.get("subscription_id") + subscription_id = payment_setup_result.get("subscription_id") + + if subscription_id: logger.info("Tenant-independent subscription created successfully", user_id=user_id, subscription_id=subscription_id) - - # Step 3: Store subscription data in onboarding progress - logger.info("Step 3: Storing subscription data in onboarding progress", - user_id=user_id) - - # Update onboarding progress with subscription data + + # Store subscription data in onboarding progress await auth_service.save_subscription_to_onboarding_progress( user_id=user_id, subscription_id=subscription_id, registration_data=user_data ) - + logger.info("Subscription data stored in onboarding progress", user_id=user_id) + + result.subscription_id = subscription_id else: - logger.warning("Subscription creation failed, but user registration succeeded", + logger.warning("No subscription ID returned, but user registration succeeded", user_id=user_id) - else: - logger.info("No subscription data provided, skipping subscription creation", - user_id=user_id) + + # Record successful registration + if metrics: + metrics.increment_counter("enhanced_registration_with_subscription_total", + labels={"status": "success"}) + + logger.info("Registration with subscription completed successfully using secure architecture", + user_id=user_id, + email=user_data.email, + subscription_id=result.subscription_id) + + return result + + except HTTPException: + raise + except Exception as e: + if metrics: + error_type = "validation_error" if "validation" in str(e).lower() else "conflict" if "conflict" in str(e).lower() else "failed" + metrics.increment_counter("enhanced_registration_with_subscription_total", + labels={"status": error_type}) + + logger.error("Registration with subscription system error using secure architecture", + email=user_data.email, + error=str(e), + exc_info=True) + + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Registration with subscription failed: " + str(e) + ) # Record successful registration if metrics: @@ -206,6 +302,30 @@ async def register_with_subscription( # Add subscription_id to the response result.subscription_id = subscription_id + + # Check if subscription creation requires 3DS/SetupIntent authentication + if subscription_result and subscription_result.get('requires_action'): + result.requires_action = subscription_result.get('requires_action') + result.action_type = subscription_result.get('action_type') + result.client_secret = subscription_result.get('client_secret') + result.setup_intent_id = subscription_result.get('setup_intent_id') + result.payment_intent_id = subscription_result.get('payment_intent_id') # Legacy, deprecated + + # Include data needed for post-3DS subscription completion + result.customer_id = subscription_result.get('customer_id') + result.plan_id = user_data.subscription_plan + result.payment_method_id = user_data.payment_method_id + result.trial_period_days = subscription_result.get('trial_period_days') + result.user_id = user_id + result.billing_interval = user_data.billing_cycle or "monthly" + result.message = subscription_result.get('message') + + logger.info("Registration requires SetupIntent authentication", + user_id=user_id, + requires_action=result.requires_action, + action_type=result.action_type, + setup_intent_id=result.setup_intent_id) + return result except HTTPException as e: @@ -675,6 +795,142 @@ async def reset_password( ) +@router.post("/api/v1/auth/complete-registration-after-setup-intent") +@track_execution_time("registration_completion_duration_seconds", "auth-service") +async def complete_registration_after_setup_intent( + completion_data: SetupIntentCompletionData, + request: Request, + auth_service: EnhancedAuthService = Depends(get_auth_service) +): + """ + Complete user registration after SetupIntent confirmation + + This endpoint is called by the frontend after 3DS authentication is complete. + It ensures users are only created after payment verification. + + Args: + completion_data: Data from frontend including SetupIntent ID and user info + + Returns: + TokenResponse with access_token, refresh_token, and user data + + Raises: + HTTPException: 400 if SetupIntent not succeeded + HTTPException: 500 if registration fails + """ + metrics = get_metrics_collector(request) + + logger.info("Completing registration after SetupIntent confirmation", + email=completion_data.email, + setup_intent_id=completion_data.setup_intent_id) + + try: + # Step 1: Verify SetupIntent using tenant service orchestration + logger.info("Step 1: Verifying SetupIntent using orchestration service", + setup_intent_id=completion_data.setup_intent_id) + + verification_result = await auth_service.verify_setup_intent_via_tenant_service( + completion_data.setup_intent_id + ) + + if not verification_result or verification_result.get('status') != 'succeeded': + status_code = status.HTTP_400_BAD_REQUEST + detail = f"SetupIntent not succeeded: {verification_result.get('status') if verification_result else 'unknown'}" + + logger.warning("SetupIntent verification failed via orchestration service", + email=completion_data.email, + setup_intent_id=completion_data.setup_intent_id, + status=verification_result.get('status') if verification_result else 'unknown') + + if metrics: + metrics.increment_counter("registration_completion_total", labels={"status": "failed_verification"}) + + raise HTTPException(status_code=status_code, detail=detail) + + logger.info("SetupIntent verification succeeded via orchestration service", + setup_intent_id=completion_data.setup_intent_id) + + # Step 2: Create user (ONLY after payment verification) + logger.info("Step 2: Creating user after successful payment verification", + email=completion_data.email) + + user_data = UserRegistration( + email=completion_data.email, + password=completion_data.password, + full_name=completion_data.full_name, + subscription_plan=completion_data.plan_id, + payment_method_id=completion_data.payment_method_id, + billing_cycle=completion_data.billing_interval, + coupon_code=completion_data.coupon_code + ) + + registration_result = await auth_service.register_user(user_data) + + logger.info("User created successfully after payment verification", + user_id=registration_result.user.id, + email=completion_data.email) + + # Step 3: Create subscription (now that user exists) + logger.info("Step 3: Creating subscription for verified user", + user_id=registration_result.user.id, + plan_id=completion_data.plan_id) + + subscription_result = await auth_service.create_subscription_via_tenant_service( + user_id=registration_result.user.id, + plan_id=completion_data.plan_id, + payment_method_id=completion_data.payment_method_id, + billing_cycle=completion_data.billing_interval, + coupon_code=completion_data.coupon_code + ) + + if not subscription_result or not subscription_result.get('success'): + logger.error("Subscription creation failed after successful user registration", + user_id=registration_result.user.id, + error="Subscription creation returned no success") + + if metrics: + metrics.increment_counter("registration_completion_total", labels={"status": "failed_subscription"}) + + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Subscription creation failed after user registration" + ) + + logger.info("Subscription created successfully", + user_id=registration_result.user.id, + subscription_id=subscription_result.get('subscription_id')) + + # Step 4: Return tokens and subscription data + registration_result.subscription_id = subscription_result.get('subscription_id') + + if metrics: + metrics.increment_counter("registration_completion_total", labels={"status": "success"}) + + logger.info("Registration completed successfully after SetupIntent confirmation", + user_id=registration_result.user.id, + email=completion_data.email, + subscription_id=subscription_result.get('subscription_id')) + + return registration_result + + except HTTPException: + raise + except Exception as e: + if metrics: + metrics.increment_counter("registration_completion_total", labels={"status": "error"}) + + logger.error("Registration completion system error", + email=completion_data.email, + setup_intent_id=completion_data.setup_intent_id, + error=str(e), + exc_info=True) + + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Registration completion failed: " + str(e) + ) + + @router.get("/api/v1/auth/health") async def health_check(): """Health check endpoint for enhanced auth service""" diff --git a/services/auth/app/api/users.py b/services/auth/app/api/users.py index 48f55ec6..85363dce 100644 --- a/services/auth/app/api/users.py +++ b/services/auth/app/api/users.py @@ -516,7 +516,7 @@ async def update_user_tenant( tenant_id=tenant_id) user_service = UserService(db) - user = await user_service.get_user_by_id(uuid.UUID(user_id)) + user = await user_service.get_user_by_id(uuid.UUID(user_id), session=db) if not user: raise HTTPException( diff --git a/services/auth/app/schemas/auth.py b/services/auth/app/schemas/auth.py index 0fd0f7df..5948ce0d 100644 --- a/services/auth/app/schemas/auth.py +++ b/services/auth/app/schemas/auth.py @@ -78,6 +78,20 @@ class TokenResponse(BaseModel): expires_in: int = 3600 # seconds user: Optional[UserData] = None subscription_id: Optional[str] = Field(None, description="Subscription ID if created during registration") + # Payment action fields (3DS, SetupIntent, etc.) + requires_action: Optional[bool] = Field(None, description="Whether payment action is required (3DS, SetupIntent confirmation)") + action_type: Optional[str] = Field(None, description="Type of action required (setup_intent_confirmation, payment_intent_confirmation)") + client_secret: Optional[str] = Field(None, description="Client secret for payment confirmation") + payment_intent_id: Optional[str] = Field(None, description="Payment intent ID for 3DS authentication") + setup_intent_id: Optional[str] = Field(None, description="SetupIntent ID for payment method verification") + customer_id: Optional[str] = Field(None, description="Stripe customer ID") + # Additional fields for post-confirmation subscription completion + plan_id: Optional[str] = Field(None, description="Subscription plan ID") + payment_method_id: Optional[str] = Field(None, description="Payment method ID") + trial_period_days: Optional[int] = Field(None, description="Trial period in days") + user_id: Optional[str] = Field(None, description="User ID for post-confirmation processing") + billing_interval: Optional[str] = Field(None, description="Billing interval (monthly, yearly)") + message: Optional[str] = Field(None, description="Additional message about payment action required") class Config: schema_extra = { @@ -95,7 +109,13 @@ class TokenResponse(BaseModel): "created_at": "2025-07-22T10:00:00Z", "role": "user" }, - "subscription_id": "sub_1234567890" + "subscription_id": "sub_1234567890", + "requires_action": True, + "action_type": "setup_intent_confirmation", + "client_secret": "seti_1234_secret_5678", + "payment_intent_id": None, + "setup_intent_id": "seti_1234567890", + "customer_id": "cus_1234567890" } } diff --git a/services/auth/app/services/auth_service.py b/services/auth/app/services/auth_service.py index 6093507d..a6633a21 100644 --- a/services/auth/app/services/auth_service.py +++ b/services/auth/app/services/auth_service.py @@ -861,6 +861,94 @@ class EnhancedAuthService: error=str(e)) return None + async def create_registration_payment_setup_via_tenant_service( + self, + user_data: UserRegistration + ) -> Dict[str, Any]: + """ + Create registration payment setup via tenant service orchestration + + This method uses the tenant service's orchestration service to create + payment customer and SetupIntent in a coordinated workflow for the + secure architecture where users are only created after payment verification. + + Args: + user_data: User registration data (email, full_name, etc.) + + Returns: + Dictionary with payment setup results including SetupIntent if required + """ + try: + from shared.clients.tenant_client import TenantServiceClient + from shared.config.base import BaseServiceSettings + + tenant_client = TenantServiceClient(BaseServiceSettings()) + + # Prepare user data for tenant service orchestration + user_data_for_tenant = { + "email": user_data.email, + "full_name": user_data.full_name, + "payment_method_id": user_data.payment_method_id, + "plan_id": user_data.subscription_plan or "professional", + "billing_cycle": user_data.billing_cycle or "monthly", + "coupon_code": user_data.coupon_code + } + + # Call tenant service orchestration endpoint + result = await tenant_client.create_registration_payment_setup(user_data_for_tenant) + + logger.info("Registration payment setup completed via tenant service orchestration", + email=user_data.email, + requires_action=result.get('requires_action'), + setup_intent_id=result.get('setup_intent_id')) + + return result + + except Exception as e: + logger.error("Registration payment setup via tenant service failed", + email=user_data.email, + error=str(e), + exc_info=True) + raise + + async def verify_setup_intent_via_tenant_service( + self, + setup_intent_id: str + ) -> Dict[str, Any]: + """ + Verify SetupIntent via tenant service orchestration + + This method uses the tenant service's orchestration service to verify + SetupIntent status before proceeding with user creation. + + Args: + setup_intent_id: SetupIntent ID to verify + + Returns: + Dictionary with SetupIntent verification result + """ + try: + from shared.clients.tenant_client import TenantServiceClient + from shared.config.base import BaseServiceSettings + + tenant_client = TenantServiceClient(BaseServiceSettings()) + + # Call tenant service orchestration endpoint + result = await tenant_client.verify_setup_intent_for_registration(setup_intent_id) + + logger.info("SetupIntent verified via tenant service orchestration", + setup_intent_id=setup_intent_id, + status=result.get('status')) + + return result + + except Exception as e: + logger.error("SetupIntent verification via tenant service failed", + setup_intent_id=setup_intent_id, + error=str(e), + exc_info=True) + raise + async def get_user_data_for_tenant_service(self, user_id: str) -> Dict[str, Any]: """ Get user data formatted for tenant service calls @@ -894,6 +982,101 @@ class EnhancedAuthService: error=str(e)) raise + async def create_payment_customer_for_registration( + self, + user_data: UserRegistration + ) -> Dict[str, Any]: + """ + Create payment customer for registration (BEFORE user creation) + + This method creates a payment customer in the tenant service + without requiring a user to exist first. This supports the + secure architecture where users are only created after payment verification. + + Args: + user_data: User registration data + + Returns: + Dictionary with payment customer creation result + + Raises: + Exception: If payment customer creation fails + """ + try: + from shared.clients.tenant_client import TenantServiceClient + from app.core.config import settings + + tenant_client = TenantServiceClient(settings) + + # Prepare user data for tenant service (without user_id) + user_data_for_tenant = { + "email": user_data.email, + "full_name": user_data.full_name, + "name": user_data.full_name + } + + # Call tenant service to create payment customer + payment_result = await tenant_client.create_payment_customer( + user_data_for_tenant, + user_data.payment_method_id + ) + + logger.info("Payment customer created for registration (pre-user creation)", + email=user_data.email, + payment_customer_id=payment_result.get("payment_customer_id") if payment_result else "unknown") + + return payment_result + + except Exception as e: + logger.error("Payment customer creation failed for registration", + email=user_data.email, + error=str(e), + exc_info=True) + raise + + async def verify_setup_intent( + self, + setup_intent_id: str + ) -> Dict[str, Any]: + """ + Verify SetupIntent status with payment provider + + This method checks if a SetupIntent has been successfully confirmed + (either automatically or via 3DS authentication). + + Args: + setup_intent_id: SetupIntent ID to verify + + Returns: + Dictionary with SetupIntent verification result + + Raises: + Exception: If verification fails + """ + try: + from shared.clients.tenant_client import TenantServiceClient + from app.core.config import settings + + tenant_client = TenantServiceClient(settings) + + # Call tenant service to verify SetupIntent + verification_result = await tenant_client.verify_setup_intent( + setup_intent_id + ) + + logger.info("SetupIntent verification result", + setup_intent_id=setup_intent_id, + status=verification_result.get("status") if verification_result else "unknown") + + return verification_result + + except Exception as e: + logger.error("SetupIntent verification failed", + setup_intent_id=setup_intent_id, + error=str(e), + exc_info=True) + raise + async def save_subscription_to_onboarding_progress( self, user_id: str, diff --git a/services/auth/app/services/user_service.py b/services/auth/app/services/user_service.py index d748d968..76eade9c 100644 --- a/services/auth/app/services/user_service.py +++ b/services/auth/app/services/user_service.py @@ -5,6 +5,7 @@ Updated to use repository pattern with dependency injection and improved error h from datetime import datetime, timezone from typing import Dict, Any, List, Optional +from sqlalchemy.ext.asyncio import AsyncSession from fastapi import HTTPException, status import structlog @@ -27,22 +28,27 @@ class EnhancedUserService: """Initialize service with database manager""" self.database_manager = database_manager - async def get_user_by_id(self, user_id: str) -> Optional[UserResponse]: + async def get_user_by_id(self, user_id: str, session: Optional[AsyncSession] = None) -> Optional[UserResponse]: """Get user by ID using repository pattern""" try: - async with self.database_manager.get_session() as session: + if session: + # Use provided session (for direct session injection) user_repo = UserRepository(User, session) - - user = await user_repo.get_by_id(user_id) - if not user: - return None - - return UserResponse( - id=str(user.id), - email=user.email, - full_name=user.full_name, - is_active=user.is_active, - is_verified=user.is_verified, + else: + # Use database manager to get session + async with self.database_manager.get_session() as session: + user_repo = UserRepository(User, session) + + user = await user_repo.get_by_id(user_id) + if not user: + return None + + return UserResponse( + id=str(user.id), + email=user.email, + full_name=user.full_name, + is_active=user.is_active, + is_verified=user.is_verified, created_at=user.created_at, role=user.role, phone=getattr(user, 'phone', None), diff --git a/services/tenant/README.md b/services/tenant/README.md index 192663f0..db658082 100644 --- a/services/tenant/README.md +++ b/services/tenant/README.md @@ -228,8 +228,8 @@ CREATE TABLE tenants ( -- Subscription subscription_tier VARCHAR(50) DEFAULT 'free', -- free, pro, enterprise - stripe_customer_id VARCHAR(255), -- Stripe customer ID - stripe_subscription_id VARCHAR(255), -- Stripe subscription ID + customer_id VARCHAR(255), -- Stripe customer ID + subscription_id VARCHAR(255), -- Stripe subscription ID -- 🆕 Enterprise hierarchy fields (NEW) parent_tenant_id UUID REFERENCES tenants(id) ON DELETE RESTRICT, @@ -271,8 +271,8 @@ CREATE INDEX idx_tenants_hierarchy_path ON tenants(hierarchy_path); CREATE TABLE tenant_subscriptions ( id UUID PRIMARY KEY, tenant_id UUID REFERENCES tenants(id) ON DELETE CASCADE, - stripe_subscription_id VARCHAR(255) UNIQUE, - stripe_customer_id VARCHAR(255), + subscription_id VARCHAR(255) UNIQUE, + customer_id VARCHAR(255), -- Plan details plan_tier VARCHAR(50) NOT NULL, -- free, pro, enterprise @@ -486,7 +486,7 @@ CREATE TABLE tenant_audit_log ( ```sql CREATE INDEX idx_tenants_status ON tenants(status); CREATE INDEX idx_tenants_subscription_tier ON tenants(subscription_tier); -CREATE INDEX idx_subscriptions_stripe ON tenant_subscriptions(stripe_subscription_id); +CREATE INDEX idx_subscriptions_stripe ON tenant_subscriptions(subscription_id); CREATE INDEX idx_subscriptions_status ON tenant_subscriptions(tenant_id, status); CREATE INDEX idx_members_tenant ON tenant_members(tenant_id); CREATE INDEX idx_members_user ON tenant_members(user_id); @@ -555,7 +555,7 @@ async def create_tenant_with_subscription( }] if tenant.tax_id else None ) - tenant.stripe_customer_id = stripe_customer.id + tenant.customer_id = stripe_customer.id # Attach payment method if provided if payment_method_id: @@ -587,13 +587,13 @@ async def create_tenant_with_subscription( stripe_subscription = stripe.Subscription.create(**subscription_params) - tenant.stripe_subscription_id = stripe_subscription.id + tenant.subscription_id = stripe_subscription.id # Create subscription record subscription = TenantSubscription( tenant_id=tenant.id, - stripe_subscription_id=stripe_subscription.id, - stripe_customer_id=stripe_customer.id, + subscription_id=stripe_subscription.id, + customer_id=stripe_customer.id, plan_tier=plan_tier, plan_interval='month', plan_amount=get_plan_amount(plan_tier), @@ -705,11 +705,11 @@ async def update_subscription( new_price_id = get_stripe_price_id(new_plan_tier, subscription.plan_interval) # Update Stripe subscription - stripe_subscription = stripe.Subscription.retrieve(subscription.stripe_subscription_id) + stripe_subscription = stripe.Subscription.retrieve(subscription.subscription_id) # Update subscription items (Stripe handles proration automatically) stripe_subscription = stripe.Subscription.modify( - subscription.stripe_subscription_id, + subscription.subscription_id, items=[{ 'id': stripe_subscription['items']['data'][0].id, 'price': new_price_id @@ -828,7 +828,7 @@ async def handle_subscription_updated(stripe_subscription: dict): tenant_id = UUID(stripe_subscription['metadata'].get('tenant_id')) subscription = await db.query(TenantSubscription).filter( - TenantSubscription.stripe_subscription_id == stripe_subscription['id'] + TenantSubscription.subscription_id == stripe_subscription['id'] ).first() if subscription: @@ -846,7 +846,7 @@ async def handle_payment_failed(stripe_invoice: dict): customer_id = stripe_invoice['customer'] tenant = await db.query(Tenant).filter( - Tenant.stripe_customer_id == customer_id + Tenant.customer_id == customer_id ).first() if tenant: @@ -923,9 +923,9 @@ async def upgrade_tenant_to_enterprise( import stripe stripe.api_key = os.getenv('STRIPE_SECRET_KEY') - stripe_subscription = stripe.Subscription.retrieve(subscription.stripe_subscription_id) + stripe_subscription = stripe.Subscription.retrieve(subscription.subscription_id) stripe.Subscription.modify( - subscription.stripe_subscription_id, + subscription.subscription_id, items=[{ 'id': stripe_subscription['items']['data'][0].id, 'price': new_price_id @@ -1052,8 +1052,8 @@ async def add_child_outlet_to_parent( # 3. Create linked subscription (child shares parent subscription) child_subscription = TenantSubscription( tenant_id=child_tenant.id, - stripe_subscription_id=None, # Linked to parent, no separate billing - stripe_customer_id=parent.stripe_customer_id, # Same customer + subscription_id=None, # Linked to parent, no separate billing + customer_id=parent.customer_id, # Same customer plan_tier='enterprise', plan_interval='month', plan_amount=Decimal('0.00'), # No additional charge diff --git a/services/tenant/app/api/internal_demo.py b/services/tenant/app/api/internal_demo.py index ed9c5650..3b682bb4 100644 --- a/services/tenant/app/api/internal_demo.py +++ b/services/tenant/app/api/internal_demo.py @@ -200,8 +200,8 @@ async def clone_demo_data( session_time, "next_billing_date" ), - stripe_subscription_id=subscription_data.get('stripe_subscription_id'), - stripe_customer_id=subscription_data.get('stripe_customer_id'), + subscription_id=subscription_data.get('stripe_subscription_id'), + customer_id=subscription_data.get('stripe_customer_id'), cancelled_at=parse_date_field( subscription_data.get('cancelled_at'), session_time, diff --git a/services/tenant/app/api/subscription.py b/services/tenant/app/api/subscription.py index 7c6938bd..c5b571b8 100644 --- a/services/tenant/app/api/subscription.py +++ b/services/tenant/app/api/subscription.py @@ -867,7 +867,7 @@ async def register_with_subscription( return { "success": True, "message": "Registration and subscription created successfully", - "data": result + **result } except Exception as e: logger.error("Failed to register with subscription", error=str(e)) @@ -924,7 +924,7 @@ async def create_subscription_endpoint( return { "success": True, "message": "Subscription created successfully", - "data": result + **result } except Exception as e: @@ -975,16 +975,30 @@ async def create_subscription_for_registration( request.billing_interval, request.coupon_code ) - + + # Check if result requires SetupIntent confirmation (3DS) + if result.get('requires_action'): + logger.info("Subscription creation requires SetupIntent confirmation", + user_id=request.user_data.get('user_id'), + action_type=result.get('action_type'), + setup_intent_id=result.get('setup_intent_id')) + + return { + "success": True, + "message": "Payment method verification required", + **result # Spread all result fields to top level for frontend compatibility + } + + # Normal subscription creation (no 3DS) logger.info("Tenant-independent subscription created successfully", user_id=request.user_data.get('user_id'), - subscription_id=result["subscription_id"], + subscription_id=result.get("subscription_id"), plan_id=request.plan_id) - + return { "success": True, "message": "Tenant-independent subscription created successfully", - "data": result + **result } except Exception as e: @@ -998,6 +1012,136 @@ async def create_subscription_for_registration( ) +@router.post("/api/v1/subscriptions/complete-after-setup-intent") +async def complete_subscription_after_setup_intent( + request: dict = Body(..., description="Completion request with setup_intent_id"), + db: AsyncSession = Depends(get_db) +): + """ + Complete subscription creation after SetupIntent confirmation + + This endpoint is called by the frontend after successfully confirming a SetupIntent + (with or without 3DS). It verifies the SetupIntent and creates the subscription. + + Request body should contain: + - setup_intent_id: The SetupIntent ID that was confirmed + - customer_id: Stripe customer ID (from initial response) + - plan_id: Subscription plan ID + - payment_method_id: Payment method ID + - trial_period_days: Optional trial period + - user_id: User ID (for linking subscription to user) + """ + try: + setup_intent_id = request.get('setup_intent_id') + customer_id = request.get('customer_id') + plan_id = request.get('plan_id') + payment_method_id = request.get('payment_method_id') + trial_period_days = request.get('trial_period_days') + user_id = request.get('user_id') + billing_interval = request.get('billing_interval', 'monthly') + + if not all([setup_intent_id, customer_id, plan_id, payment_method_id, user_id]): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Missing required fields: setup_intent_id, customer_id, plan_id, payment_method_id, user_id" + ) + + logger.info("Completing subscription after SetupIntent confirmation", + setup_intent_id=setup_intent_id, + user_id=user_id, + plan_id=plan_id) + + # Use orchestration service to complete subscription + orchestration_service = SubscriptionOrchestrationService(db) + + result = await orchestration_service.complete_subscription_after_setup_intent( + setup_intent_id=setup_intent_id, + customer_id=customer_id, + plan_id=plan_id, + payment_method_id=payment_method_id, + trial_period_days=trial_period_days, + user_id=user_id, + billing_interval=billing_interval + ) + + logger.info("Subscription completed successfully after SetupIntent", + setup_intent_id=setup_intent_id, + subscription_id=result.get('subscription_id'), + user_id=user_id) + + return { + "success": True, + "message": "Subscription created successfully after SetupIntent confirmation", + **result + } + + except HTTPException: + raise + except Exception as e: + logger.error("Failed to complete subscription after SetupIntent", + error=str(e), + setup_intent_id=request.get('setup_intent_id')) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Failed to complete subscription: {str(e)}" + ) + + +@router.get("/api/v1/subscriptions/{tenant_id}/payment-method") +async def get_payment_method( + tenant_id: str = Path(..., description="Tenant ID"), + current_user: dict = Depends(get_current_user_dep), + db: AsyncSession = Depends(get_db) +): + """ + Get the current payment method for a subscription + + This endpoint retrieves the current payment method details from the payment provider + for display in the UI, including brand, last4 digits, and expiration date. + """ + try: + # Use SubscriptionOrchestrationService to get payment method + orchestration_service = SubscriptionOrchestrationService(db) + + payment_method = await orchestration_service.get_payment_method(tenant_id) + + if not payment_method: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="No payment method found for this subscription" + ) + + logger.info("payment_method_retrieved_via_api", + tenant_id=tenant_id, + user_id=current_user.get("user_id")) + + return payment_method + + except HTTPException: + raise + except ValidationError as ve: + logger.error("get_payment_method_validation_failed", + error=str(ve), tenant_id=tenant_id) + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=str(ve) + ) + except DatabaseError as de: + logger.error("get_payment_method_failed", + error=str(de), tenant_id=tenant_id) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Failed to retrieve payment method" + ) + except Exception as e: + logger.error("get_payment_method_unexpected_error", + error=str(e), tenant_id=tenant_id) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="An unexpected error occurred while retrieving payment method" + ) + + @router.post("/api/v1/subscriptions/{tenant_id}/update-payment-method") async def update_payment_method( tenant_id: str = Path(..., description="Tenant ID"), @@ -1007,60 +1151,43 @@ async def update_payment_method( ): """ Update the default payment method for a subscription - + This endpoint allows users to change their payment method through the UI. - It updates the default payment method in Stripe and returns the updated - payment method information. + It updates the default payment method with the payment provider and returns + the updated payment method information. """ try: - # Use SubscriptionService to get subscription and update payment method - subscription_service = SubscriptionService(db) - - # Get current subscription - subscription = await subscription_service.get_subscription_by_tenant_id(tenant_id) - if not subscription: - raise ValidationError(f"Subscription not found for tenant {tenant_id}") - - if not subscription.stripe_customer_id: - raise ValidationError(f"Tenant {tenant_id} does not have a Stripe customer ID") - - # Update payment method via PaymentService - payment_result = await subscription_service.payment_service.update_payment_method( - subscription.stripe_customer_id, + # Use SubscriptionOrchestrationService to update payment method + orchestration_service = SubscriptionOrchestrationService(db) + + result = await orchestration_service.update_payment_method( + tenant_id, payment_method_id ) - + logger.info("Payment method updated successfully", tenant_id=tenant_id, payment_method_id=payment_method_id, user_id=current_user.get("user_id")) - - return { - "success": True, - "message": "Payment method updated successfully", - "payment_method_id": payment_result.id, - "brand": getattr(payment_result, 'brand', 'unknown'), - "last4": getattr(payment_result, 'last4', '0000'), - "exp_month": getattr(payment_result, 'exp_month', None), - "exp_year": getattr(payment_result, 'exp_year', None) - } - + + return result + except ValidationError as ve: - logger.error("update_payment_method_validation_failed", + logger.error("update_payment_method_validation_failed", error=str(ve), tenant_id=tenant_id) raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail=str(ve) ) except DatabaseError as de: - logger.error("update_payment_method_failed", + logger.error("update_payment_method_failed", error=str(de), tenant_id=tenant_id) raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Failed to update payment method" ) except Exception as e: - logger.error("update_payment_method_unexpected_error", + logger.error("update_payment_method_unexpected_error", error=str(e), tenant_id=tenant_id) raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, @@ -1377,3 +1504,118 @@ async def redeem_coupon( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="An unexpected error occurred while redeeming coupon" ) + + +# NEW ENDPOINTS FOR SECURE REGISTRATION ARCHITECTURE + +class PaymentCustomerCreationRequest(BaseModel): + """Request model for payment customer creation (pre-user-creation)""" + user_data: Dict[str, Any] + payment_method_id: Optional[str] = None + + +@router.post("/payment-customers/create") +async def create_payment_customer_for_registration( + request: PaymentCustomerCreationRequest, + db: AsyncSession = Depends(get_db) +): + """ + Create payment customer (supports pre-user-creation flow) + + This endpoint creates a payment customer without requiring a user_id, + supporting the secure architecture where users are only created after + payment verification. + + Uses SubscriptionOrchestrationService for proper workflow coordination. + + Args: + request: Payment customer creation request + + Returns: + Dictionary with payment customer creation result + """ + try: + logger.info("Creating payment customer for registration (pre-user creation)", + email=request.user_data.get('email'), + payment_method_id=request.payment_method_id) + + # Use orchestration service for proper workflow coordination + orchestration_service = SubscriptionOrchestrationService(db) + + result = await orchestration_service.create_registration_payment_setup( + user_data=request.user_data, + plan_id=request.plan_id if hasattr(request, 'plan_id') else "professional", + payment_method_id=request.payment_method_id, + billing_interval="monthly", # Default for registration + coupon_code=request.user_data.get('coupon_code') + ) + + logger.info("Payment setup completed for registration", + email=request.user_data.get('email'), + requires_action=result.get('requires_action'), + setup_intent_id=result.get('setup_intent_id')) + + return { + "success": True, + **result # Include all orchestration service results + } + + except Exception as e: + logger.error("Failed to create payment customer for registration", + email=request.user_data.get('email'), + error=str(e), + exc_info=True) + + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Failed to create payment customer: " + str(e) + ) + + +@router.get("/setup-intents/{setup_intent_id}/verify") +async def verify_setup_intent( + setup_intent_id: str, + db: AsyncSession = Depends(get_db) +): + """ + Verify SetupIntent status with payment provider + + This endpoint checks if a SetupIntent has been successfully confirmed + (either automatically or via 3DS authentication). + + Uses SubscriptionOrchestrationService for proper workflow coordination. + + Args: + setup_intent_id: SetupIntent ID to verify + + Returns: + Dictionary with SetupIntent verification result + """ + try: + logger.info("Verifying SetupIntent status", + setup_intent_id=setup_intent_id) + + # Use orchestration service for proper workflow coordination + orchestration_service = SubscriptionOrchestrationService(db) + + # Verify SetupIntent using orchestration service + result = await orchestration_service.verify_setup_intent_for_registration( + setup_intent_id + ) + + logger.info("SetupIntent verification result", + setup_intent_id=setup_intent_id, + status=result.get('status')) + + return result + + except Exception as e: + logger.error("Failed to verify SetupIntent", + setup_intent_id=setup_intent_id, + error=str(e), + exc_info=True) + + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Failed to verify SetupIntent: " + str(e) + ) diff --git a/services/tenant/app/api/tenant_operations.py b/services/tenant/app/api/tenant_operations.py index cfdd77a9..f24de765 100644 --- a/services/tenant/app/api/tenant_operations.py +++ b/services/tenant/app/api/tenant_operations.py @@ -1138,7 +1138,7 @@ async def register_with_subscription( ): """Process user registration with subscription creation""" -@router.post(route_builder.build_base_route("payment-customers/create", include_tenant_prefix=False)) +@router.post("/api/v1/payment-customers/create") async def create_payment_customer( user_data: Dict[str, Any], payment_method_id: Optional[str] = Query(None, description="Optional payment method ID"), @@ -1146,7 +1146,7 @@ async def create_payment_customer( ): """ Create a payment customer in the payment provider - + This endpoint is designed for service-to-service communication from auth service during user registration. It creates a payment customer that can be used later for subscription creation. @@ -1241,7 +1241,7 @@ async def register_with_subscription( return { "success": True, "message": "Registration and subscription created successfully", - "data": result + **result } except Exception as e: logger.error("Failed to register with subscription", error=str(e)) @@ -1291,7 +1291,7 @@ async def link_subscription_to_tenant( return { "success": True, "message": "Subscription linked to tenant successfully", - "data": result + **result } except Exception as e: diff --git a/services/tenant/app/models/tenants.py b/services/tenant/app/models/tenants.py index 65c71207..18945482 100644 --- a/services/tenant/app/models/tenants.py +++ b/services/tenant/app/models/tenants.py @@ -171,8 +171,10 @@ class Subscription(Base): trial_ends_at = Column(DateTime(timezone=True)) cancelled_at = Column(DateTime(timezone=True), nullable=True) cancellation_effective_date = Column(DateTime(timezone=True), nullable=True) - stripe_subscription_id = Column(String(255), nullable=True) - stripe_customer_id = Column(String(255), nullable=True) + + # Payment provider references (generic names for provider-agnostic design) + subscription_id = Column(String(255), nullable=True) # Payment provider subscription ID + customer_id = Column(String(255), nullable=True) # Payment provider customer ID # Limits max_users = Column(Integer, default=5) diff --git a/services/tenant/app/repositories/subscription_repository.py b/services/tenant/app/repositories/subscription_repository.py index cd8ebf01..07c26657 100644 --- a/services/tenant/app/repositories/subscription_repository.py +++ b/services/tenant/app/repositories/subscription_repository.py @@ -120,12 +120,12 @@ class SubscriptionRepository(TenantBaseRepository): error=str(e)) raise DatabaseError(f"Failed to get subscription: {str(e)}") - async def get_by_stripe_id(self, stripe_subscription_id: str) -> Optional[Subscription]: - """Get subscription by Stripe subscription ID""" + async def get_by_provider_id(self, subscription_id: str) -> Optional[Subscription]: + """Get subscription by payment provider subscription ID""" try: subscriptions = await self.get_multi( filters={ - "stripe_subscription_id": stripe_subscription_id + "subscription_id": subscription_id }, limit=1, order_by="created_at", @@ -133,8 +133,8 @@ class SubscriptionRepository(TenantBaseRepository): ) return subscriptions[0] if subscriptions else None except Exception as e: - logger.error("Failed to get subscription by Stripe ID", - stripe_subscription_id=stripe_subscription_id, + logger.error("Failed to get subscription by provider ID", + subscription_id=subscription_id, error=str(e)) raise DatabaseError(f"Failed to get subscription: {str(e)}") @@ -514,7 +514,7 @@ class SubscriptionRepository(TenantBaseRepository): """Create a subscription not linked to any tenant (for registration flow)""" try: # Validate required data for tenant-independent subscription - required_fields = ["user_id", "plan", "stripe_subscription_id", "stripe_customer_id"] + required_fields = ["user_id", "plan", "subscription_id", "customer_id"] validation_result = self._validate_tenant_data(subscription_data, required_fields) if not validation_result["is_valid"]: diff --git a/services/tenant/app/repositories/tenant_repository.py b/services/tenant/app/repositories/tenant_repository.py index c23e73e5..a258cc17 100644 --- a/services/tenant/app/repositories/tenant_repository.py +++ b/services/tenant/app/repositories/tenant_repository.py @@ -396,6 +396,10 @@ class TenantRepository(TenantBaseRepository): error=str(e)) raise DatabaseError(f"Failed to get child tenants: {str(e)}") + async def get(self, record_id: Any) -> Optional[Tenant]: + """Get tenant by ID - alias for get_by_id for compatibility""" + return await self.get_by_id(record_id) + async def get_child_tenant_count(self, parent_tenant_id: str) -> int: """Get count of child tenants for a parent tenant""" try: diff --git a/services/tenant/app/services/payment_service.py b/services/tenant/app/services/payment_service.py index cd848cb6..2b3b71ec 100644 --- a/services/tenant/app/services/payment_service.py +++ b/services/tenant/app/services/payment_service.py @@ -4,8 +4,9 @@ This service handles ONLY payment provider interactions (Stripe, etc.) NO business logic, NO database operations, NO orchestration """ +import asyncio import structlog -from typing import Dict, Any, Optional, List +from typing import Dict, Any, Optional, List, Callable, Type from datetime import datetime from app.core.config import settings @@ -15,6 +16,51 @@ from shared.clients.stripe_client import StripeProvider logger = structlog.get_logger() +async def retry_with_backoff( + func: Callable, + max_retries: int = 3, + base_delay: float = 1.0, + max_delay: float = 10.0, + exceptions: tuple = (Exception,) +): + """ + Generic retry function with exponential backoff + + Args: + func: The async function to retry + max_retries: Maximum number of retry attempts + base_delay: Initial delay between retries in seconds + max_delay: Maximum delay between retries in seconds + exceptions: Tuple of exception types to retry on + """ + for attempt in range(max_retries + 1): + try: + return await func() + except exceptions as e: + if attempt == max_retries: + # Last attempt, re-raise the exception + raise e + + # Calculate delay with exponential backoff and jitter + delay = min(base_delay * (2 ** attempt), max_delay) + jitter = delay * 0.1 # 10% jitter + actual_delay = delay + (jitter * (attempt % 2)) # Alternate between + and - jitter + + logger.warning( + "Payment provider API call failed, retrying", + attempt=attempt + 1, + max_retries=max_retries, + delay=actual_delay, + error=str(e), + error_type=type(e).__name__ + ) + + await asyncio.sleep(actual_delay) + + # This should never be reached, but included for completeness + raise Exception("Max retries exceeded") + + class PaymentService: """Service for handling payment provider interactions ONLY""" @@ -37,7 +83,13 @@ class PaymentService: } } - return await self.payment_provider.create_customer(customer_data) + # Use retry logic for transient Stripe API failures + result = await retry_with_backoff( + lambda: self.payment_provider.create_customer(customer_data), + max_retries=3, + exceptions=(Exception,) # Catch all exceptions for payment provider calls + ) + return result except Exception as e: logger.error("Failed to create customer in payment provider", error=str(e)) raise e @@ -49,7 +101,7 @@ class PaymentService: payment_method_id: str, trial_period_days: Optional[int] = None, billing_interval: str = "monthly" - ) -> Subscription: + ) -> Dict[str, Any]: """ Create a subscription in the payment provider @@ -61,18 +113,25 @@ class PaymentService: billing_interval: Billing interval (monthly/yearly) Returns: - Subscription object from payment provider + Dictionary containing subscription and authentication details """ try: # Map the plan ID to the actual Stripe price ID stripe_price_id = self._get_stripe_price_id(plan_id, billing_interval) - return await self.payment_provider.create_subscription( - customer_id, - stripe_price_id, - payment_method_id, - trial_period_days + # Use retry logic for transient Stripe API failures + result = await retry_with_backoff( + lambda: self.payment_provider.create_subscription( + customer_id, + stripe_price_id, + payment_method_id, + trial_period_days + ), + max_retries=3, + exceptions=(Exception,) # Catch all exceptions for payment provider calls ) + + return result except Exception as e: logger.error("Failed to create subscription in payment provider", error=str(e), @@ -127,7 +186,7 @@ class PaymentService: logger.error("Failed to cancel subscription in payment provider", error=str(e)) raise e - async def update_payment_method(self, customer_id: str, payment_method_id: str) -> PaymentMethod: + async def update_payment_method(self, customer_id: str, payment_method_id: str) -> Dict[str, Any]: """ Update the payment method for a customer @@ -136,10 +195,16 @@ class PaymentService: payment_method_id: New payment method ID Returns: - PaymentMethod object + Dictionary containing payment method and authentication details """ try: - return await self.payment_provider.update_payment_method(customer_id, payment_method_id) + # Use retry logic for transient Stripe API failures + result = await retry_with_backoff( + lambda: self.payment_provider.update_payment_method(customer_id, payment_method_id), + max_retries=3, + exceptions=(Exception,) # Catch all exceptions for payment provider calls + ) + return result except Exception as e: logger.error("Failed to update payment method in payment provider", error=str(e)) raise e @@ -155,11 +220,76 @@ class PaymentService: Subscription object """ try: - return await self.payment_provider.get_subscription(subscription_id) + # Use retry logic for transient Stripe API failures + result = await retry_with_backoff( + lambda: self.payment_provider.get_subscription(subscription_id), + max_retries=3, + exceptions=(Exception,) # Catch all exceptions for payment provider calls + ) + return result except Exception as e: logger.error("Failed to get subscription from payment provider", error=str(e)) raise e + async def complete_subscription_after_setup_intent( + self, + setup_intent_id: str, + customer_id: str, + plan_id: str, + payment_method_id: str, + trial_period_days: Optional[int] = None + ) -> Dict[str, Any]: + """ + Complete subscription creation after SetupIntent has been confirmed + + This method is called after the frontend confirms a SetupIntent (with or without 3DS). + It verifies the SetupIntent and creates the subscription with the verified payment method. + + Args: + setup_intent_id: The SetupIntent ID that was confirmed + customer_id: Payment provider customer ID + plan_id: Subscription plan ID + payment_method_id: Payment method ID + trial_period_days: Optional trial period in days + + Returns: + Dictionary containing subscription details + """ + try: + logger.info("Completing subscription after SetupIntent via payment service", + setup_intent_id=setup_intent_id, + customer_id=customer_id, + plan_id=plan_id) + + # Map plan ID to Stripe price ID (default to monthly) + stripe_price_id = self._get_stripe_price_id(plan_id, "monthly") + + # Use retry logic for transient Stripe API failures + result = await retry_with_backoff( + lambda: self.payment_provider.complete_subscription_after_setup_intent( + setup_intent_id, + customer_id, + stripe_price_id, + payment_method_id, + trial_period_days + ), + max_retries=3, + exceptions=(Exception,) + ) + + logger.info("Subscription completed successfully after SetupIntent", + setup_intent_id=setup_intent_id, + subscription_id=result['subscription'].id if 'subscription' in result else None) + + return result + except Exception as e: + logger.error("Failed to complete subscription after SetupIntent in payment service", + error=str(e), + setup_intent_id=setup_intent_id, + customer_id=customer_id, + exc_info=True) + raise e + async def update_payment_subscription( self, subscription_id: str, @@ -184,14 +314,20 @@ class PaymentService: Updated Subscription object """ try: - return await self.payment_provider.update_subscription( - subscription_id, - new_price_id, - proration_behavior, - billing_cycle_anchor, - payment_behavior, - immediate_change + # Use retry logic for transient Stripe API failures + result = await retry_with_backoff( + lambda: self.payment_provider.update_subscription( + subscription_id, + new_price_id, + proration_behavior, + billing_cycle_anchor, + payment_behavior, + immediate_change + ), + max_retries=3, + exceptions=(Exception,) # Catch all exceptions for payment provider calls ) + return result except Exception as e: logger.error("Failed to update subscription in payment provider", error=str(e)) raise e @@ -214,11 +350,17 @@ class PaymentService: Dictionary with proration details """ try: - return await self.payment_provider.calculate_proration( - subscription_id, - new_price_id, - proration_behavior + # Use retry logic for transient Stripe API failures + result = await retry_with_backoff( + lambda: self.payment_provider.calculate_proration( + subscription_id, + new_price_id, + proration_behavior + ), + max_retries=3, + exceptions=(Exception,) # Catch all exceptions for payment provider calls ) + return result except Exception as e: logger.error("Failed to calculate proration", error=str(e)) raise e @@ -241,11 +383,17 @@ class PaymentService: Updated Subscription object """ try: - return await self.payment_provider.change_billing_cycle( - subscription_id, - new_billing_cycle, - proration_behavior + # Use retry logic for transient Stripe API failures + result = await retry_with_backoff( + lambda: self.payment_provider.change_billing_cycle( + subscription_id, + new_billing_cycle, + proration_behavior + ), + max_retries=3, + exceptions=(Exception,) # Catch all exceptions for payment provider calls ) + return result except Exception as e: logger.error("Failed to change billing cycle", error=str(e)) raise e @@ -264,8 +412,12 @@ class PaymentService: List of invoice dictionaries """ try: - # Fetch invoices from payment provider - stripe_invoices = await self.payment_provider.get_invoices(customer_id) + # Use retry logic for transient Stripe API failures + stripe_invoices = await retry_with_backoff( + lambda: self.payment_provider.get_invoices(customer_id), + max_retries=3, + exceptions=(Exception,) # Catch all exceptions for payment provider calls + ) # Transform to response format invoices = [] @@ -328,6 +480,28 @@ class PaymentService: logger.error("Failed to verify webhook signature", error=str(e)) raise e + async def get_customer_payment_method(self, customer_id: str) -> Optional[PaymentMethod]: + """ + Get the current payment method for a customer + + Args: + customer_id: Payment provider customer ID + + Returns: + PaymentMethod object or None if no payment method exists + """ + try: + # Use retry logic for transient Stripe API failures + result = await retry_with_backoff( + lambda: self.payment_provider.get_customer_payment_method(customer_id), + max_retries=3, + exceptions=(Exception,) # Catch all exceptions for payment provider calls + ) + return result + except Exception as e: + logger.error("Failed to get customer payment method", error=str(e), customer_id=customer_id) + return None + async def process_registration_with_subscription( self, user_data: Dict[str, Any], @@ -338,20 +512,20 @@ class PaymentService: ) -> Dict[str, Any]: """ Process user registration with subscription creation - + This method handles the complete flow: 1. Create payment customer (if not exists) 2. Attach payment method to customer 3. Create subscription with coupon/trial 4. Return subscription details - + Args: user_data: User data including email, name, etc. plan_id: Subscription plan ID payment_method_id: Payment method ID from frontend coupon_code: Optional coupon code for discounts/trials billing_interval: Billing interval (monthly/yearly) - + Returns: Dictionary with subscription and customer details """ @@ -361,7 +535,7 @@ class PaymentService: logger.info("Payment customer created for registration", customer_id=customer.id, email=user_data.get('email')) - + # Step 2: Attach payment method to customer if payment_method_id: try: @@ -375,7 +549,7 @@ class PaymentService: error=str(e)) # Continue without attached payment method - user can add it later payment_method = None - + # Step 3: Determine trial period from coupon trial_period_days = None if coupon_code: @@ -391,7 +565,7 @@ class PaymentService: # Other coupons might provide different trial periods # This would be configured in your coupon system trial_period_days = 30 # Default trial for other coupons - + # Step 4: Create subscription subscription = await self.create_payment_subscription( customer.id, @@ -400,13 +574,13 @@ class PaymentService: trial_period_days, billing_interval ) - + logger.info("Subscription created successfully during registration", subscription_id=subscription.id, customer_id=customer.id, plan_id=plan_id, status=subscription.status) - + # Step 5: Return comprehensive result return { "success": True, @@ -434,10 +608,132 @@ class PaymentService: "coupon_applied": coupon_code is not None, "trial_active": trial_period_days is not None and trial_period_days > 0 } - + except Exception as e: logger.error("Failed to process registration with subscription", error=str(e), plan_id=plan_id, customer_email=user_data.get('email')) raise e + + async def create_payment_customer( + self, + user_data: Dict[str, Any], + payment_method_id: Optional[str] = None + ) -> Dict[str, Any]: + """ + Create payment customer (supports pre-user-creation flow) + + This method creates a payment customer without requiring a user_id, + supporting the secure architecture where users are only created after + payment verification. + + Args: + user_data: User data (email, full_name, etc.) + payment_method_id: Optional payment method ID + + Returns: + Dictionary with payment customer creation result + """ + try: + # Create customer without user_id (for pre-user-creation flow) + customer_data = { + 'email': user_data.get('email'), + 'name': user_data.get('full_name'), + 'metadata': { + 'registration_flow': 'pre_user_creation', + 'timestamp': datetime.now(timezone.utc).isoformat() + } + } + + # Create customer in payment provider + customer = await retry_with_backoff( + lambda: self.payment_provider.create_customer(customer_data), + max_retries=3, + exceptions=(Exception,) + ) + + logger.info("Payment customer created for registration (pre-user creation)", + customer_id=customer.id, + email=user_data.get('email')) + + # Optionally attach payment method if provided + payment_method = None + if payment_method_id: + try: + payment_method = await self.update_payment_method( + customer.id, + payment_method_id + ) + logger.info("Payment method attached to customer (pre-user creation)", + customer_id=customer.id, + payment_method_id=payment_method.id) + except Exception as e: + logger.warning("Failed to attach payment method during pre-user creation", + customer_id=customer.id, + error=str(e)) + # Continue without payment method - can be added later + + return { + "success": True, + "payment_customer_id": customer.id, + "customer_id": customer.id, + "email": user_data.get('email'), + "payment_method_id": payment_method.id if payment_method else None, + "payment_method_type": payment_method.type if payment_method else None, + "payment_method_last4": payment_method.last4 if payment_method else None + } + + except Exception as e: + logger.error("Failed to create payment customer for registration", + email=user_data.get('email'), + error=str(e), + exc_info=True) + raise + + async def verify_setup_intent( + self, + setup_intent_id: str + ) -> Dict[str, Any]: + """ + Verify SetupIntent status with payment provider + + This method checks if a SetupIntent has been successfully confirmed + (either automatically or via 3DS authentication). + + Args: + setup_intent_id: SetupIntent ID to verify + + Returns: + Dictionary with SetupIntent verification result + """ + try: + # Retrieve SetupIntent from payment provider + setup_intent = await retry_with_backoff( + lambda: self.payment_provider.get_setup_intent(setup_intent_id), + max_retries=3, + exceptions=(Exception,) + ) + + logger.info("SetupIntent verification result", + setup_intent_id=setup_intent_id, + status=setup_intent.status) + + return { + "success": True, + "setup_intent_id": setup_intent.id, + "status": setup_intent.status, + "customer_id": setup_intent.customer, + "payment_method_id": setup_intent.payment_method, + "created": setup_intent.created, + "last_setup_error": setup_intent.last_setup_error, + "next_action": setup_intent.next_action, + "usage": setup_intent.usage + } + + except Exception as e: + logger.error("Failed to verify SetupIntent", + setup_intent_id=setup_intent_id, + error=str(e), + exc_info=True) + raise diff --git a/services/tenant/app/services/subscription_orchestration_service.py b/services/tenant/app/services/subscription_orchestration_service.py index d68da1dd..f834328e 100644 --- a/services/tenant/app/services/subscription_orchestration_service.py +++ b/services/tenant/app/services/subscription_orchestration_service.py @@ -121,7 +121,7 @@ class SubscriptionOrchestrationService: # Step 4: Create local subscription record logger.info("Creating local subscription record", tenant_id=tenant_id, - stripe_subscription_id=stripe_subscription.id) + subscription_id=stripe_subscription.id) subscription_record = await self.subscription_service.create_subscription_record( tenant_id, @@ -141,7 +141,7 @@ class SubscriptionOrchestrationService: tenant_id=tenant_id) tenant_update_data = { - 'stripe_customer_id': customer.id, + 'customer_id': customer.id, 'subscription_status': stripe_subscription.status, 'subscription_plan': plan_id, 'subscription_tier': plan_id, @@ -265,13 +265,13 @@ class SubscriptionOrchestrationService: coupon_code=coupon_code, error=error) - # Step 3: Create subscription in payment provider + # Step 3: Create subscription in payment provider (or get SetupIntent for 3DS) logger.info("Creating subscription in payment provider", customer_id=customer.id, plan_id=plan_id, trial_period_days=trial_period_days) - stripe_subscription = await self.payment_service.create_payment_subscription( + subscription_result = await self.payment_service.create_payment_subscription( customer.id, plan_id, payment_method_id, @@ -279,6 +279,35 @@ class SubscriptionOrchestrationService: billing_interval ) + # Check if result requires 3DS authentication (SetupIntent confirmation) + if isinstance(subscription_result, dict) and subscription_result.get('requires_action'): + logger.info("Subscription creation requires SetupIntent confirmation", + customer_id=customer.id, + action_type=subscription_result.get('action_type'), + setup_intent_id=subscription_result.get('setup_intent_id')) + + # Return the SetupIntent data for frontend to handle 3DS + return { + "requires_action": True, + "action_type": subscription_result.get('action_type'), + "client_secret": subscription_result.get('client_secret'), + "setup_intent_id": subscription_result.get('setup_intent_id'), + "customer_id": customer.id, + "payment_method_id": payment_method_id, + "plan_id": plan_id, + "trial_period_days": trial_period_days, + "billing_interval": billing_interval, + "message": subscription_result.get('message'), + "user_id": user_data.get('user_id') + } + + # Extract subscription object from result + # Result can be either a dict with 'subscription' key or the subscription object directly + if isinstance(subscription_result, dict) and 'subscription' in subscription_result: + stripe_subscription = subscription_result['subscription'] + else: + stripe_subscription = subscription_result + logger.info("Subscription created in payment provider", subscription_id=stripe_subscription.id, status=stripe_subscription.status) @@ -286,7 +315,7 @@ class SubscriptionOrchestrationService: # Step 4: Create local subscription record WITHOUT tenant_id logger.info("Creating tenant-independent subscription record", user_id=user_data.get('user_id'), - stripe_subscription_id=stripe_subscription.id) + subscription_id=stripe_subscription.id) subscription_record = await self.subscription_service.create_tenant_independent_subscription_record( stripe_subscription.id, @@ -345,6 +374,100 @@ class SubscriptionOrchestrationService: error=str(e), user_id=user_data.get('user_id')) raise DatabaseError(f"Failed to create tenant-independent subscription: {str(e)}") + async def complete_subscription_after_setup_intent( + self, + setup_intent_id: str, + customer_id: str, + plan_id: str, + payment_method_id: str, + trial_period_days: Optional[int], + user_id: str, + billing_interval: str = "monthly" + ) -> Dict[str, Any]: + """ + Complete subscription creation after SetupIntent has been confirmed + + This method is called after the frontend successfully confirms a SetupIntent + (with or without 3DS). It creates the subscription with the verified payment method + and creates a database record. + + Args: + setup_intent_id: The confirmed SetupIntent ID + customer_id: Stripe customer ID + plan_id: Subscription plan ID + payment_method_id: Verified payment method ID + trial_period_days: Optional trial period + user_id: User ID for linking + billing_interval: Billing interval + + Returns: + Dictionary with subscription details + """ + try: + logger.info("Completing subscription after SetupIntent confirmation", + setup_intent_id=setup_intent_id, + user_id=user_id, + plan_id=plan_id) + + # Call payment service to complete subscription creation + result = await self.payment_service.complete_subscription_after_setup_intent( + setup_intent_id, + customer_id, + plan_id, + payment_method_id, + trial_period_days + ) + + stripe_subscription = result['subscription'] + + logger.info("Subscription created in payment provider after SetupIntent", + subscription_id=stripe_subscription.id, + status=stripe_subscription.status) + + # Create local subscription record WITHOUT tenant_id (tenant-independent) + subscription_record = await self.subscription_service.create_tenant_independent_subscription_record( + stripe_subscription.id, + customer_id, + plan_id, + stripe_subscription.status, + trial_period_days, + billing_interval, + user_id + ) + + logger.info("Tenant-independent subscription record created after SetupIntent", + subscription_id=stripe_subscription.id, + user_id=user_id) + + # Convert current_period_end to ISO format + current_period_end = stripe_subscription.current_period_end + if isinstance(current_period_end, int): + current_period_end = datetime.fromtimestamp(current_period_end, tz=timezone.utc).isoformat() + elif hasattr(current_period_end, 'isoformat'): + current_period_end = current_period_end.isoformat() + else: + current_period_end = str(current_period_end) + + return { + "success": True, + "customer_id": customer_id, + "subscription_id": stripe_subscription.id, + "status": stripe_subscription.status, + "plan": plan_id, + "billing_cycle": billing_interval, + "trial_period_days": trial_period_days, + "current_period_end": current_period_end, + "user_id": user_id, + "setup_intent_id": setup_intent_id + } + + except Exception as e: + logger.error("Failed to complete subscription after SetupIntent", + error=str(e), + setup_intent_id=setup_intent_id, + user_id=user_id) + raise DatabaseError(f"Failed to complete subscription: {str(e)}") + async def orchestrate_subscription_cancellation( self, tenant_id: str, @@ -383,7 +506,7 @@ class SubscriptionOrchestrationService: ) logger.info("Subscription cancelled in payment provider", - stripe_subscription_id=stripe_subscription.id, + subscription_id=stripe_subscription.id, stripe_status=stripe_subscription.status) # Step 4: Sync status back to database @@ -536,7 +659,7 @@ class SubscriptionOrchestrationService: ) logger.info("Plan updated in payment provider", - stripe_subscription_id=updated_stripe_subscription.id, + subscription_id=updated_stripe_subscription.id, new_status=updated_stripe_subscription.status) # Step 5: Update local subscription record @@ -622,7 +745,7 @@ class SubscriptionOrchestrationService: ) logger.info("Billing cycle changed in payment provider", - stripe_subscription_id=updated_stripe_subscription.id, + subscription_id=updated_stripe_subscription.id, new_billing_cycle=new_billing_cycle) # Step 3: Get proration details (if available) @@ -771,6 +894,26 @@ class SubscriptionOrchestrationService: await self._handle_subscription_resumed(event_data) result["actions_taken"].append("subscription_resumed") + elif event_type == 'payment_intent.succeeded': + await self._handle_payment_intent_succeeded(event_data) + result["actions_taken"].append("payment_intent_succeeded") + + elif event_type == 'payment_intent.payment_failed': + await self._handle_payment_intent_failed(event_data) + result["actions_taken"].append("payment_intent_failed") + + elif event_type == 'payment_intent.requires_action': + await self._handle_payment_intent_requires_action(event_data) + result["actions_taken"].append("payment_intent_requires_action") + + elif event_type == 'setup_intent.succeeded': + await self._handle_setup_intent_succeeded(event_data) + result["actions_taken"].append("setup_intent_succeeded") + + elif event_type == 'setup_intent.requires_action': + await self._handle_setup_intent_requires_action(event_data) + result["actions_taken"].append("setup_intent_requires_action") + else: logger.info("Unhandled webhook event type", event_type=event_type) result["processed"] = False @@ -800,7 +943,7 @@ class SubscriptionOrchestrationService: status=status) # Find tenant by customer ID - tenant = await self.tenant_service.get_tenant_by_stripe_customer_id(customer_id) + tenant = await self.tenant_service.get_tenant_by_customer_id(customer_id) if tenant: # Update subscription status @@ -896,7 +1039,7 @@ class SubscriptionOrchestrationService: customer_id=customer_id) # Find tenant and update payment status - tenant = await self.tenant_service.get_tenant_by_stripe_customer_id(customer_id) + tenant = await self.tenant_service.get_tenant_by_customer_id(customer_id) if tenant: tenant_update_data = { @@ -924,7 +1067,7 @@ class SubscriptionOrchestrationService: customer_id=customer_id) # Find tenant and update payment status - tenant = await self.tenant_service.get_tenant_by_stripe_customer_id(customer_id) + tenant = await self.tenant_service.get_tenant_by_customer_id(customer_id) if tenant: tenant_update_data = { @@ -951,7 +1094,7 @@ class SubscriptionOrchestrationService: customer_id=customer_id, trial_end=trial_end) - tenant = await self.tenant_service.get_tenant_by_stripe_customer_id(customer_id) + tenant = await self.tenant_service.get_tenant_by_customer_id(customer_id) if tenant: tenant_update_data = { @@ -978,7 +1121,7 @@ class SubscriptionOrchestrationService: customer_id=customer_id, subscription_id=subscription_id) - tenant = await self.tenant_service.get_tenant_by_stripe_customer_id(customer_id) + tenant = await self.tenant_service.get_tenant_by_customer_id(customer_id) if tenant: tenant_update_data = { @@ -1004,7 +1147,7 @@ class SubscriptionOrchestrationService: subscription_id=subscription_id, customer_id=customer_id) - tenant = await self.tenant_service.get_tenant_by_stripe_customer_id(customer_id) + tenant = await self.tenant_service.get_tenant_by_customer_id(customer_id) if tenant: await self.subscription_service.update_subscription_status( @@ -1038,7 +1181,7 @@ class SubscriptionOrchestrationService: subscription_id=subscription_id, customer_id=customer_id) - tenant = await self.tenant_service.get_tenant_by_stripe_customer_id(customer_id) + tenant = await self.tenant_service.get_tenant_by_customer_id(customer_id) if tenant: await self.subscription_service.update_subscription_status( @@ -1062,6 +1205,155 @@ class SubscriptionOrchestrationService: tenant_id=str(tenant.id), subscription_id=subscription_id) + async def _handle_payment_intent_succeeded(self, event_data: Dict[str, Any]): + """Handle payment intent succeeded event (including 3DS authenticated payments)""" + payment_intent_id = event_data['id'] + customer_id = event_data.get('customer') + amount = event_data.get('amount', 0) / 100.0 + currency = event_data.get('currency', 'eur').upper() + + logger.info("Handling payment intent succeeded event", + payment_intent_id=payment_intent_id, + customer_id=customer_id, + amount=amount) + + if customer_id: + tenant = await self.tenant_service.get_tenant_by_customer_id(customer_id) + + if tenant: + tenant_update_data = { + 'payment_action_required': False, + 'last_successful_payment_at': datetime.now(timezone.utc) + } + + await self.tenant_service.update_tenant_subscription_info( + str(tenant.id), tenant_update_data + ) + + logger.info("Payment intent succeeded event handled", + tenant_id=str(tenant.id), + payment_intent_id=payment_intent_id) + + async def _handle_payment_intent_failed(self, event_data: Dict[str, Any]): + """Handle payment intent failed event (including 3DS authentication failures)""" + payment_intent_id = event_data['id'] + customer_id = event_data.get('customer') + last_payment_error = event_data.get('last_payment_error', {}) + error_message = last_payment_error.get('message', 'Payment failed') + + logger.warning("Handling payment intent failed event", + payment_intent_id=payment_intent_id, + customer_id=customer_id, + error_message=error_message) + + if customer_id: + tenant = await self.tenant_service.get_tenant_by_customer_id(customer_id) + + if tenant: + tenant_update_data = { + 'payment_action_required': False, + 'last_payment_failure_at': datetime.now(timezone.utc), + 'last_payment_error': error_message + } + + await self.tenant_service.update_tenant_subscription_info( + str(tenant.id), tenant_update_data + ) + + logger.info("Payment intent failed event handled", + tenant_id=str(tenant.id), + payment_intent_id=payment_intent_id) + + async def _handle_payment_intent_requires_action(self, event_data: Dict[str, Any]): + """Handle payment intent requires action event (3DS authentication needed)""" + payment_intent_id = event_data['id'] + customer_id = event_data.get('customer') + next_action = event_data.get('next_action', {}) + action_type = next_action.get('type', 'unknown') + + logger.info("Handling payment intent requires action event", + payment_intent_id=payment_intent_id, + customer_id=customer_id, + action_type=action_type) + + if customer_id: + tenant = await self.tenant_service.get_tenant_by_customer_id(customer_id) + + if tenant: + tenant_update_data = { + 'payment_action_required': True, + 'payment_action_type': action_type, + 'last_payment_action_required_at': datetime.now(timezone.utc) + } + + await self.tenant_service.update_tenant_subscription_info( + str(tenant.id), tenant_update_data + ) + + logger.info("Payment intent requires action event handled", + tenant_id=str(tenant.id), + payment_intent_id=payment_intent_id, + action_type=action_type) + + async def _handle_setup_intent_succeeded(self, event_data: Dict[str, Any]): + """Handle setup intent succeeded event (3DS authentication completed)""" + setup_intent_id = event_data['id'] + customer_id = event_data.get('customer') + + logger.info("Handling setup intent succeeded event (3DS authentication completed)", + setup_intent_id=setup_intent_id, + customer_id=customer_id) + + if customer_id: + tenant = await self.tenant_service.get_tenant_by_customer_id(customer_id) + + if tenant: + tenant_update_data = { + 'threeds_authentication_completed': True, + 'threeds_authentication_completed_at': datetime.now(timezone.utc), + 'last_threeds_setup_intent_id': setup_intent_id + } + + await self.tenant_service.update_tenant_subscription_info( + str(tenant.id), tenant_update_data + ) + + logger.info("Setup intent succeeded event handled (3DS authentication completed)", + tenant_id=str(tenant.id), + setup_intent_id=setup_intent_id) + + async def _handle_setup_intent_requires_action(self, event_data: Dict[str, Any]): + """Handle setup intent requires action event (3DS authentication needed)""" + setup_intent_id = event_data['id'] + customer_id = event_data.get('customer') + next_action = event_data.get('next_action', {}) + action_type = next_action.get('type', 'unknown') + + logger.info("Handling setup intent requires action event (3DS authentication needed)", + setup_intent_id=setup_intent_id, + customer_id=customer_id, + action_type=action_type) + + if customer_id: + tenant = await self.tenant_service.get_tenant_by_customer_id(customer_id) + + if tenant: + tenant_update_data = { + 'threeds_authentication_required': True, + 'threeds_authentication_required_at': datetime.now(timezone.utc), + 'last_threeds_setup_intent_id': setup_intent_id, + 'threeds_action_type': action_type + } + + await self.tenant_service.update_tenant_subscription_info( + str(tenant.id), tenant_update_data + ) + + logger.info("Setup intent requires action event handled (3DS authentication needed)", + tenant_id=str(tenant.id), + setup_intent_id=setup_intent_id, + action_type=action_type) + async def orchestrate_subscription_creation_with_default_payment( self, tenant_id: str, @@ -1165,3 +1457,310 @@ class SubscriptionOrchestrationService: error=str(e)) # Don't fail the subscription creation if we can't get the default payment method return None + + async def get_payment_method(self, tenant_id: str) -> Optional[Dict[str, Any]]: + """ + Get the current payment method for a tenant's subscription + + This is an orchestration method that coordinates between: + 1. SubscriptionService (to get subscription data) + 2. PaymentService (to get payment method from provider) + + Args: + tenant_id: Tenant ID + + Returns: + Dictionary with payment method details or None + """ + try: + # Get subscription from database + subscription = await self.subscription_service.get_subscription_by_tenant_id(tenant_id) + + if not subscription: + logger.warning("get_payment_method_no_subscription", + tenant_id=tenant_id) + return None + + # Check if subscription has a customer ID + if not subscription.customer_id: + logger.warning("get_payment_method_no_customer_id", + tenant_id=tenant_id) + return None + + # Get payment method from payment provider + payment_method = await self.payment_service.get_customer_payment_method(subscription.customer_id) + + if not payment_method: + logger.info("get_payment_method_not_found", + tenant_id=tenant_id, + customer_id=subscription.customer_id) + return None + + logger.info("payment_method_retrieved", + tenant_id=tenant_id, + payment_method_type=payment_method.type, + last4=payment_method.last4) + + return { + "brand": payment_method.brand, + "last4": payment_method.last4, + "exp_month": payment_method.exp_month, + "exp_year": payment_method.exp_year + } + + except Exception as e: + logger.error("get_payment_method_failed", + error=str(e), + tenant_id=tenant_id) + return None + + async def update_payment_method( + self, + tenant_id: str, + payment_method_id: str + ) -> Dict[str, Any]: + """ + Update the default payment method for a tenant's subscription + + This is an orchestration method that coordinates between: + 1. SubscriptionService (to get subscription data) + 2. PaymentService (to update payment method with provider) + + Args: + tenant_id: Tenant ID + payment_method_id: New payment method ID from frontend + + Returns: + Dictionary with updated payment method details + + Raises: + ValidationError: If subscription or customer_id not found + """ + try: + # Get subscription from database + subscription = await self.subscription_service.get_subscription_by_tenant_id(tenant_id) + + if not subscription: + raise ValidationError(f"Subscription not found for tenant {tenant_id}") + + if not subscription.customer_id: + raise ValidationError(f"Tenant {tenant_id} does not have a payment customer ID") + + # Update payment method via payment provider + payment_result = await self.payment_service.update_payment_method( + subscription.customer_id, + payment_method_id + ) + + logger.info("payment_method_updated", + tenant_id=tenant_id, + payment_method_id=payment_method_id, + requires_action=payment_result.get('requires_action', False)) + + pm_details = payment_result.get('payment_method', {}) + + return { + "success": True, + "message": "Payment method updated successfully", + "payment_method_id": pm_details.get('id'), + "brand": pm_details.get('brand', 'unknown'), + "last4": pm_details.get('last4', '0000'), + "exp_month": pm_details.get('exp_month'), + "exp_year": pm_details.get('exp_year'), + "requires_action": payment_result.get('requires_action', False), + "client_secret": payment_result.get('client_secret'), + "payment_intent_status": payment_result.get('payment_intent_status') + } + + except ValidationError: + raise + except Exception as e: + logger.error("update_payment_method_failed", + error=str(e), + tenant_id=tenant_id) + raise DatabaseError(f"Failed to update payment method: {str(e)}") + + async def create_registration_payment_setup( + self, + user_data: Dict[str, Any], + plan_id: str, + payment_method_id: str, + billing_interval: str = "monthly", + coupon_code: Optional[str] = None + ) -> Dict[str, Any]: + """ + Create payment customer and SetupIntent for registration (pre-user-creation) + + This method supports the secure architecture where users are only created + after payment verification. It creates a payment customer and SetupIntent + without requiring a user_id. + + Args: + user_data: User data (email, full_name, etc.) - NO user_id required + plan_id: Subscription plan ID + payment_method_id: Payment method ID from frontend + billing_interval: Billing interval (monthly/yearly) + coupon_code: Optional coupon code + + Returns: + Dictionary with payment setup results including SetupIntent if required + + Raises: + Exception: If payment setup fails + """ + try: + logger.info("Starting registration payment setup (pre-user-creation)", + email=user_data.get('email'), + plan_id=plan_id) + + # Step 1: Create payment customer (without user_id) + logger.info("Creating payment customer for registration", + email=user_data.get('email')) + + # Create customer without user_id metadata + customer_data = { + 'email': user_data.get('email'), + 'name': user_data.get('full_name'), + 'metadata': { + 'registration_flow': 'pre_user_creation', + 'timestamp': datetime.now(timezone.utc).isoformat() + } + } + + customer = await self.payment_service.create_customer(customer_data) + logger.info("Payment customer created for registration", + customer_id=customer.id, + email=user_data.get('email')) + + # Step 2: Handle coupon logic (if provided) + trial_period_days = 0 + coupon_discount = None + + if coupon_code: + logger.info("Validating and redeeming coupon code for registration", + coupon_code=coupon_code, + email=user_data.get('email')) + + coupon_service = CouponService(self.db_session) + success, discount_applied, error = await coupon_service.redeem_coupon( + coupon_code, + None, # No tenant_id yet + base_trial_days=0 + ) + + if success and discount_applied: + coupon_discount = discount_applied + trial_period_days = discount_applied.get("total_trial_days", 0) + logger.info("Coupon redeemed successfully for registration", + coupon_code=coupon_code, + trial_period_days=trial_period_days) + else: + logger.warning("Failed to redeem coupon for registration, continuing without it", + coupon_code=coupon_code, + error=error) + + # Step 3: Create subscription/SetupIntent + logger.info("Creating subscription/SetupIntent for registration", + customer_id=customer.id, + plan_id=plan_id, + payment_method_id=payment_method_id) + + subscription_result = await self.payment_service.create_payment_subscription( + customer.id, + plan_id, + payment_method_id, + trial_period_days if trial_period_days > 0 else None, + billing_interval + ) + + # Check if result requires 3DS authentication (SetupIntent confirmation) + if isinstance(subscription_result, dict) and subscription_result.get('requires_action'): + logger.info("Registration payment setup requires SetupIntent confirmation", + customer_id=customer.id, + action_type=subscription_result.get('action_type'), + setup_intent_id=subscription_result.get('setup_intent_id')) + + # Return the SetupIntent data for frontend to handle 3DS + return { + "requires_action": True, + "action_type": subscription_result.get('action_type'), + "client_secret": subscription_result.get('client_secret'), + "setup_intent_id": subscription_result.get('setup_intent_id'), + "customer_id": customer.id, + "payment_customer_id": customer.id, + "plan_id": plan_id, + "payment_method_id": payment_method_id, + "trial_period_days": trial_period_days, + "billing_interval": billing_interval, + "coupon_applied": coupon_code is not None, + "email": user_data.get('email'), + "full_name": user_data.get('full_name'), + "message": subscription_result.get('message') or "Payment verification required before account creation" + } + else: + # No 3DS required - subscription created successfully + logger.info("Registration payment setup completed without 3DS", + customer_id=customer.id, + subscription_id=subscription_result.get('subscription_id')) + + return { + "requires_action": False, + "subscription_id": subscription_result.get('subscription_id'), + "customer_id": customer.id, + "payment_customer_id": customer.id, + "plan_id": plan_id, + "payment_method_id": payment_method_id, + "trial_period_days": trial_period_days, + "billing_interval": billing_interval, + "coupon_applied": coupon_code is not None, + "email": user_data.get('email'), + "full_name": user_data.get('full_name'), + "message": "Payment setup completed successfully" + } + + except Exception as e: + logger.error("Registration payment setup failed", + email=user_data.get('email'), + error=str(e), + exc_info=True) + raise + + async def verify_setup_intent_for_registration( + self, + setup_intent_id: str + ) -> Dict[str, Any]: + """ + Verify SetupIntent status for registration completion + + This method checks if a SetupIntent has been successfully confirmed + (either automatically or via 3DS authentication) before proceeding + with user creation. + + Args: + setup_intent_id: SetupIntent ID to verify + + Returns: + Dictionary with SetupIntent verification result + + Raises: + Exception: If verification fails + """ + try: + logger.info("Verifying SetupIntent for registration completion", + setup_intent_id=setup_intent_id) + + # Use payment service to verify SetupIntent + verification_result = await self.payment_service.verify_setup_intent(setup_intent_id) + + logger.info("SetupIntent verification result for registration", + setup_intent_id=setup_intent_id, + status=verification_result.get('status')) + + return verification_result + + except Exception as e: + logger.error("SetupIntent verification failed for registration", + setup_intent_id=setup_intent_id, + error=str(e), + exc_info=True) + raise diff --git a/services/tenant/app/services/subscription_service.py b/services/tenant/app/services/subscription_service.py index 6f87fa81..c3bc20cb 100644 --- a/services/tenant/app/services/subscription_service.py +++ b/services/tenant/app/services/subscription_service.py @@ -30,8 +30,8 @@ class SubscriptionService: async def create_subscription_record( self, tenant_id: str, - stripe_subscription_id: str, - stripe_customer_id: str, + subscription_id: str, + customer_id: str, plan: str, status: str, trial_period_days: Optional[int] = None, @@ -42,8 +42,8 @@ class SubscriptionService: Args: tenant_id: Tenant ID - stripe_subscription_id: Stripe subscription ID - stripe_customer_id: Stripe customer ID + subscription_id: Payment provider subscription ID + customer_id: Payment provider customer ID plan: Subscription plan status: Subscription status trial_period_days: Optional trial period in days @@ -66,8 +66,8 @@ class SubscriptionService: # Create local subscription record subscription_data = { 'tenant_id': str(tenant_id), - 'subscription_id': stripe_subscription_id, # Stripe subscription ID - 'customer_id': stripe_customer_id, # Stripe customer ID + 'subscription_id': subscription_id, + 'customer_id': customer_id, 'plan_id': plan, 'status': status, 'created_at': datetime.now(timezone.utc), @@ -79,7 +79,7 @@ class SubscriptionService: logger.info("subscription_record_created", tenant_id=tenant_id, - subscription_id=stripe_subscription_id, + subscription_id=subscription_id, plan=plan) return created_subscription @@ -181,24 +181,24 @@ class SubscriptionService: error=str(e), tenant_id=tenant_id) return None - async def get_subscription_by_stripe_id( + async def get_subscription_by_provider_id( self, - stripe_subscription_id: str + subscription_id: str ) -> Optional[Subscription]: """ - Get subscription by Stripe subscription ID + Get subscription by payment provider subscription ID Args: - stripe_subscription_id: Stripe subscription ID + subscription_id: Payment provider subscription ID Returns: Subscription object or None """ try: - return await self.subscription_repo.get_by_stripe_id(stripe_subscription_id) + return await self.subscription_repo.get_by_provider_id(subscription_id) except Exception as e: - logger.error("get_subscription_by_stripe_id_failed", - error=str(e), stripe_subscription_id=stripe_subscription_id) + logger.error("get_subscription_by_provider_id_failed", + error=str(e), subscription_id=subscription_id) return None async def cancel_subscription( @@ -587,8 +587,8 @@ class SubscriptionService: async def create_tenant_independent_subscription_record( self, - stripe_subscription_id: str, - stripe_customer_id: str, + subscription_id: str, + customer_id: str, plan: str, status: str, trial_period_days: Optional[int] = None, @@ -601,8 +601,8 @@ class SubscriptionService: This subscription is not linked to any tenant and will be linked during onboarding Args: - stripe_subscription_id: Stripe subscription ID - stripe_customer_id: Stripe customer ID + subscription_id: Payment provider subscription ID + customer_id: Payment provider customer ID plan: Subscription plan status: Subscription status trial_period_days: Optional trial period in days @@ -615,8 +615,8 @@ class SubscriptionService: try: # Create tenant-independent subscription record subscription_data = { - 'stripe_subscription_id': stripe_subscription_id, # Stripe subscription ID - 'stripe_customer_id': stripe_customer_id, # Stripe customer ID + 'subscription_id': subscription_id, + 'customer_id': customer_id, 'plan': plan, # Repository expects 'plan', not 'plan_id' 'status': status, 'created_at': datetime.now(timezone.utc), @@ -630,7 +630,7 @@ class SubscriptionService: created_subscription = await self.subscription_repo.create_tenant_independent_subscription(subscription_data) logger.info("tenant_independent_subscription_record_created", - subscription_id=stripe_subscription_id, + subscription_id=subscription_id, user_id=user_id, plan=plan) diff --git a/services/tenant/app/services/tenant_service.py b/services/tenant/app/services/tenant_service.py index 64702948..fc749744 100644 --- a/services/tenant/app/services/tenant_service.py +++ b/services/tenant/app/services/tenant_service.py @@ -1445,7 +1445,7 @@ class EnhancedTenantService: # Update tenant with subscription information tenant_update = { - "stripe_customer_id": subscription.customer_id, + "customer_id": subscription.customer_id, "subscription_status": subscription.status, "subscription_plan": subscription.plan, "subscription_tier": subscription.plan, diff --git a/services/tenant/migrations/versions/001_unified_initial_schema.py b/services/tenant/migrations/versions/001_unified_initial_schema.py index 708946cb..9c13b223 100644 --- a/services/tenant/migrations/versions/001_unified_initial_schema.py +++ b/services/tenant/migrations/versions/001_unified_initial_schema.py @@ -204,8 +204,8 @@ def upgrade() -> None: sa.Column('trial_ends_at', sa.DateTime(timezone=True), nullable=True), sa.Column('cancelled_at', sa.DateTime(timezone=True), nullable=True), sa.Column('cancellation_effective_date', sa.DateTime(timezone=True), nullable=True), - sa.Column('stripe_subscription_id', sa.String(255), nullable=True), - sa.Column('stripe_customer_id', sa.String(255), nullable=True), + sa.Column('subscription_id', sa.String(255), nullable=True), + sa.Column('customer_id', sa.String(255), nullable=True), # Basic resource limits sa.Column('max_users', sa.Integer(), nullable=True), sa.Column('max_locations', sa.Integer(), nullable=True), @@ -284,24 +284,24 @@ def upgrade() -> None: WHERE status = 'active' """) - # Index 5: Stripe subscription lookup (for webhook processing) - if not _index_exists(connection, 'idx_subscriptions_stripe_sub_id'): + # Index 5: Subscription ID lookup (for webhook processing) + if not _index_exists(connection, 'idx_subscriptions_subscription_id'): op.create_index( - 'idx_subscriptions_stripe_sub_id', + 'idx_subscriptions_subscription_id', 'subscriptions', - ['stripe_subscription_id'], + ['subscription_id'], unique=False, - postgresql_where=sa.text("stripe_subscription_id IS NOT NULL") + postgresql_where=sa.text("subscription_id IS NOT NULL") ) - # Index 6: Stripe customer lookup (for customer-related operations) - if not _index_exists(connection, 'idx_subscriptions_stripe_customer_id'): + # Index 6: Customer ID lookup (for customer-related operations) + if not _index_exists(connection, 'idx_subscriptions_customer_id'): op.create_index( - 'idx_subscriptions_stripe_customer_id', + 'idx_subscriptions_customer_id', 'subscriptions', - ['stripe_customer_id'], + ['customer_id'], unique=False, - postgresql_where=sa.text("stripe_customer_id IS NOT NULL") + postgresql_where=sa.text("customer_id IS NOT NULL") ) # Index 7: User ID for tenant linking @@ -481,8 +481,8 @@ def downgrade() -> None: # Drop subscriptions table indexes first op.drop_index('idx_subscriptions_linking_status', table_name='subscriptions') op.drop_index('idx_subscriptions_user_id', table_name='subscriptions') - op.drop_index('idx_subscriptions_stripe_customer_id', table_name='subscriptions') - op.drop_index('idx_subscriptions_stripe_sub_id', table_name='subscriptions') + op.drop_index('idx_subscriptions_customer_id', table_name='subscriptions') + op.drop_index('idx_subscriptions_subscription_id', table_name='subscriptions') op.drop_index('idx_subscriptions_active_tenant', table_name='subscriptions') op.drop_index('idx_subscriptions_status_billing', table_name='subscriptions') op.drop_index('idx_subscriptions_tenant_covering', table_name='subscriptions') diff --git a/shared/clients/payment_client.py b/shared/clients/payment_client.py index 8e063aa4..e0a360c6 100755 --- a/shared/clients/payment_client.py +++ b/shared/clients/payment_client.py @@ -38,6 +38,13 @@ class Subscription: created_at: datetime billing_cycle_anchor: Optional[datetime] = None cancel_at_period_end: Optional[bool] = None + # 3DS Authentication fields + payment_intent_id: Optional[str] = None + payment_intent_status: Optional[str] = None + payment_intent_client_secret: Optional[str] = None + requires_action: Optional[bool] = None + trial_end: Optional[datetime] = None + billing_interval: Optional[str] = None @dataclass diff --git a/shared/clients/stripe_client.py b/shared/clients/stripe_client.py index ef7d0c82..83bb482a 100755 --- a/shared/clients/stripe_client.py +++ b/shared/clients/stripe_client.py @@ -15,6 +15,11 @@ from .payment_client import PaymentProvider, PaymentCustomer, PaymentMethod, Sub logger = structlog.get_logger() +class PaymentVerificationError(Exception): + """Exception raised when payment method verification fails""" + pass + + class StripeProvider(PaymentProvider): """ Stripe implementation of the PaymentProvider interface @@ -62,20 +67,23 @@ class StripeProvider(PaymentProvider): email=customer_data.get('email')) raise e - async def create_subscription(self, customer_id: str, plan_id: str, payment_method_id: str, trial_period_days: Optional[int] = None) -> Subscription: + async def create_subscription(self, customer_id: str, plan_id: str, payment_method_id: str, trial_period_days: Optional[int] = None) -> Dict[str, Any]: """ Create a subscription in Stripe with idempotency and enhanced error handling + + Returns: + Dictionary containing subscription details and any required authentication actions """ try: subscription_idempotency_key = f"create_subscription_{uuid.uuid4()}" payment_method_idempotency_key = f"attach_pm_{uuid.uuid4()}" customer_update_idempotency_key = f"update_customer_{uuid.uuid4()}" - - logger.info("Creating Stripe subscription", + + logger.info("Creating Stripe subscription", customer_id=customer_id, plan_id=plan_id, payment_method_id=payment_method_id) - + # Attach payment method to customer with idempotency and error handling try: stripe.PaymentMethod.attach( @@ -94,7 +102,7 @@ class StripeProvider(PaymentProvider): payment_method_id=payment_method_id) else: raise - + # Set customer's default payment method with idempotency stripe.Customer.modify( customer_id, @@ -103,10 +111,68 @@ class StripeProvider(PaymentProvider): }, idempotency_key=customer_update_idempotency_key ) - - logger.info("Customer default payment method updated", + + logger.info("Customer default payment method updated", customer_id=customer_id) - + + # Verify payment method before creating subscription (especially important for trial periods) + # This ensures the card is valid and can be charged after the trial + # Use SetupIntent for card verification without immediate payment + # + # CRITICAL FOR 3DS SUPPORT: + # We do NOT confirm the SetupIntent here because: + # 1. If 3DS is required, we need the frontend to handle the redirect + # 2. The frontend will confirm the SetupIntent with the return_url + # 3. After 3DS completion, frontend will call us again with the verified payment method + # + # This prevents creating subscriptions with unverified payment methods. + + setup_intent_params = { + 'customer': customer_id, + 'payment_method': payment_method_id, + 'usage': 'off_session', # Allow charging without customer presence after verification + 'idempotency_key': f"verify_payment_{uuid.uuid4()}", + 'expand': ['payment_method'], + 'confirm': False # Explicitly don't confirm yet - frontend will handle 3DS + } + + try: + # Create SetupIntent WITHOUT confirming + # Frontend will confirm with return_url if 3DS is needed + verification_intent = stripe.SetupIntent.create(**setup_intent_params) + + logger.info("SetupIntent created for payment method verification", + setup_intent_id=verification_intent.id, + status=verification_intent.status, + payment_method_id=payment_method_id) + + # ALWAYS return the SetupIntent for frontend to confirm + # Frontend will handle 3DS if needed, or confirm immediately if not + # This ensures proper 3DS flow for all cards + return { + 'requires_action': True, # Frontend must always confirm + 'action_type': 'setup_intent_confirmation', + 'client_secret': verification_intent.client_secret, + 'setup_intent_id': verification_intent.id, + 'status': verification_intent.status, + 'customer_id': customer_id, + 'payment_method_id': payment_method_id, + 'plan_id': plan_id, + 'trial_period_days': trial_period_days, + 'message': 'Payment method verification required. Frontend must confirm SetupIntent.' + } + + except stripe.error.CardError as e: + logger.error("Payment method verification failed", + error=str(e), + code=e.code, + decline_code=e.decline_code) + raise PaymentVerificationError(f"Card verification failed: {e.user_message or str(e)}") + except Exception as e: + logger.error("Unexpected error during payment verification", + error=str(e)) + raise PaymentVerificationError(f"Payment verification error: {str(e)}") + # Create subscription with trial period if specified subscription_params = { 'customer': customer_id, @@ -115,29 +181,28 @@ class StripeProvider(PaymentProvider): 'idempotency_key': subscription_idempotency_key, 'expand': ['latest_invoice.payment_intent'] } - + if trial_period_days: subscription_params['trial_period_days'] = trial_period_days - logger.info("Subscription includes trial period", + logger.info("Subscription includes trial period", trial_period_days=trial_period_days) - + stripe_subscription = stripe.Subscription.create(**subscription_params) # Handle period dates for trial vs active subscriptions - # During trial: current_period_* fields are only in subscription items, not root - # After trial: current_period_* fields are at root level - if stripe_subscription.status == 'trialing' and stripe_subscription.items and stripe_subscription.items.data: - # For trial subscriptions, get period from first subscription item - first_item = stripe_subscription.items.data[0] - current_period_start = first_item.current_period_start - current_period_end = first_item.current_period_end - logger.info("Stripe trial subscription created successfully", - subscription_id=stripe_subscription.id, - status=stripe_subscription.status, - trial_end=stripe_subscription.trial_end, - current_period_end=current_period_end) + if stripe_subscription.status == 'trialing' and hasattr(stripe_subscription, 'items') and stripe_subscription.items: + # Access items properly - stripe_subscription.items is typically a list-like object + items_list = stripe_subscription.items.data if hasattr(stripe_subscription.items, 'data') else stripe_subscription.items + if items_list and len(items_list) > 0: + first_item = items_list[0] if isinstance(items_list, list) else items_list + current_period_start = first_item.current_period_start + current_period_end = first_item.current_period_end + logger.info("Stripe trial subscription created successfully", + subscription_id=stripe_subscription.id, + status=stripe_subscription.status, + trial_end=stripe_subscription.trial_end, + current_period_end=current_period_end) else: - # For active subscriptions, get period from root level current_period_start = stripe_subscription.current_period_start current_period_end = stripe_subscription.current_period_end logger.info("Stripe subscription created successfully", @@ -145,15 +210,53 @@ class StripeProvider(PaymentProvider): status=stripe_subscription.status, current_period_end=current_period_end) - return Subscription( + # Check if payment requires action (3D Secure, SCA) + requires_action = False + client_secret = None + payment_intent_status = None + + if stripe_subscription.latest_invoice: + if hasattr(stripe_subscription.latest_invoice, 'payment_intent') and stripe_subscription.latest_invoice.payment_intent: + payment_intent = stripe_subscription.latest_invoice.payment_intent + payment_intent_status = payment_intent.status + + if payment_intent.status in ['requires_action', 'requires_source_action']: + requires_action = True + client_secret = payment_intent.client_secret + logger.info("Subscription payment requires authentication", + subscription_id=stripe_subscription.id, + payment_intent_id=payment_intent.id, + status=payment_intent.status) + + # Calculate trial end if this is a trial subscription + trial_end_timestamp = None + if stripe_subscription.status == 'trialing' and hasattr(stripe_subscription, 'trial_end'): + trial_end_timestamp = stripe_subscription.trial_end + + subscription_obj = Subscription( id=stripe_subscription.id, customer_id=stripe_subscription.customer, - plan_id=plan_id, # Using the price ID as plan_id + plan_id=plan_id, status=stripe_subscription.status, current_period_start=datetime.fromtimestamp(current_period_start), current_period_end=datetime.fromtimestamp(current_period_end), - created_at=datetime.fromtimestamp(stripe_subscription.created) + created_at=datetime.fromtimestamp(stripe_subscription.created), + billing_cycle_anchor=datetime.fromtimestamp(stripe_subscription.billing_cycle_anchor) if hasattr(stripe_subscription, 'billing_cycle_anchor') else None, + cancel_at_period_end=stripe_subscription.cancel_at_period_end if hasattr(stripe_subscription, 'cancel_at_period_end') else None, + payment_intent_id=stripe_subscription.latest_invoice.payment_intent.id if (hasattr(stripe_subscription.latest_invoice, 'payment_intent') and stripe_subscription.latest_invoice.payment_intent) else None, + payment_intent_status=payment_intent_status, + payment_intent_client_secret=client_secret, + requires_action=requires_action, + trial_end=datetime.fromtimestamp(trial_end_timestamp) if trial_end_timestamp else None, + billing_interval="monthly" # Default, should be extracted from plan ) + + return { + 'subscription': subscription_obj, + 'requires_action': requires_action, + 'client_secret': client_secret, + 'payment_intent_status': payment_intent_status + } except stripe.error.CardError as e: logger.error("Card error during subscription creation", error=str(e), @@ -175,9 +278,134 @@ class StripeProvider(PaymentProvider): plan_id=plan_id) raise e - async def update_payment_method(self, customer_id: str, payment_method_id: str) -> PaymentMethod: + async def complete_subscription_after_setup_intent( + self, + setup_intent_id: str, + customer_id: str, + plan_id: str, + payment_method_id: str, + trial_period_days: Optional[int] = None + ) -> Dict[str, Any]: + """ + Complete subscription creation after SetupIntent has been confirmed by frontend + + This method should be called after the frontend successfully confirms a SetupIntent + (with or without 3DS). It verifies the SetupIntent is in 'succeeded' status and + then creates the subscription with the now-verified payment method. + + Args: + setup_intent_id: The SetupIntent ID that was confirmed + customer_id: Stripe customer ID + plan_id: Subscription plan/price ID + payment_method_id: Payment method ID (should match SetupIntent) + trial_period_days: Optional trial period + + Returns: + Dictionary containing subscription details + """ + try: + subscription_idempotency_key = f"complete_subscription_{uuid.uuid4()}" + + logger.info("Completing subscription after SetupIntent confirmation", + setup_intent_id=setup_intent_id, + customer_id=customer_id, + plan_id=plan_id) + + # Verify the SetupIntent is in succeeded status + setup_intent = stripe.SetupIntent.retrieve(setup_intent_id) + + if setup_intent.status != 'succeeded': + logger.error("SetupIntent not in succeeded status", + setup_intent_id=setup_intent_id, + status=setup_intent.status) + raise PaymentVerificationError( + f"SetupIntent must be in 'succeeded' status. Current status: {setup_intent.status}" + ) + + logger.info("SetupIntent verified as succeeded, creating subscription", + setup_intent_id=setup_intent_id) + + # Payment method is already attached and verified via SetupIntent + # Now create the subscription + subscription_params = { + 'customer': customer_id, + 'items': [{'price': plan_id}], + 'default_payment_method': payment_method_id, + 'idempotency_key': subscription_idempotency_key, + 'expand': ['latest_invoice.payment_intent'] + } + + if trial_period_days: + subscription_params['trial_period_days'] = trial_period_days + logger.info("Subscription includes trial period", + trial_period_days=trial_period_days) + + stripe_subscription = stripe.Subscription.create(**subscription_params) + + # Handle period dates for trial vs active subscriptions + if stripe_subscription.status == 'trialing' and hasattr(stripe_subscription, 'items') and stripe_subscription.items: + items_list = stripe_subscription.items.data if hasattr(stripe_subscription.items, 'data') else stripe_subscription.items + if items_list and len(items_list) > 0: + first_item = items_list[0] if isinstance(items_list, list) else items_list + current_period_start = first_item.current_period_start + current_period_end = first_item.current_period_end + logger.info("Stripe trial subscription created after SetupIntent", + subscription_id=stripe_subscription.id, + status=stripe_subscription.status, + trial_end=stripe_subscription.trial_end, + current_period_end=current_period_end) + else: + current_period_start = stripe_subscription.current_period_start + current_period_end = stripe_subscription.current_period_end + logger.info("Stripe subscription created after SetupIntent", + subscription_id=stripe_subscription.id, + status=stripe_subscription.status, + current_period_end=current_period_end) + + # Calculate trial end if this is a trial subscription + trial_end_timestamp = None + if stripe_subscription.status == 'trialing' and hasattr(stripe_subscription, 'trial_end'): + trial_end_timestamp = stripe_subscription.trial_end + + subscription_obj = Subscription( + id=stripe_subscription.id, + customer_id=stripe_subscription.customer, + plan_id=plan_id, + status=stripe_subscription.status, + current_period_start=datetime.fromtimestamp(current_period_start), + current_period_end=datetime.fromtimestamp(current_period_end), + created_at=datetime.fromtimestamp(stripe_subscription.created), + billing_cycle_anchor=datetime.fromtimestamp(stripe_subscription.billing_cycle_anchor) if hasattr(stripe_subscription, 'billing_cycle_anchor') else None, + cancel_at_period_end=stripe_subscription.cancel_at_period_end if hasattr(stripe_subscription, 'cancel_at_period_end') else None, + payment_intent_id=stripe_subscription.latest_invoice.payment_intent.id if (hasattr(stripe_subscription.latest_invoice, 'payment_intent') and stripe_subscription.latest_invoice.payment_intent) else None, + payment_intent_status=None, + payment_intent_client_secret=None, + requires_action=False, + trial_end=datetime.fromtimestamp(trial_end_timestamp) if trial_end_timestamp else None, + billing_interval="monthly" + ) + + return { + 'subscription': subscription_obj, + 'requires_action': False, + 'client_secret': None, + 'payment_intent_status': None, + 'setup_intent_id': setup_intent_id + } + + except stripe.error.StripeError as e: + logger.error("Failed to complete subscription after SetupIntent", + error=str(e), + setup_intent_id=setup_intent_id, + customer_id=customer_id) + raise e + + async def update_payment_method(self, customer_id: str, payment_method_id: str) -> Dict[str, Any]: """ Update the payment method for a customer in Stripe + + Returns: + Dictionary containing payment method details and any required authentication actions """ try: # Attach payment method to customer with error handling @@ -199,23 +427,58 @@ class StripeProvider(PaymentProvider): raise # Set as default payment method - stripe.Customer.modify( + customer = stripe.Customer.modify( customer_id, invoice_settings={ 'default_payment_method': payment_method_id } ) - + stripe_payment_method = stripe.PaymentMethod.retrieve(payment_method_id) - - return PaymentMethod( - id=stripe_payment_method.id, - type=stripe_payment_method.type, - brand=getattr(stripe_payment_method, stripe_payment_method.type, {}).get('brand'), - last4=getattr(stripe_payment_method, stripe_payment_method.type, {}).get('last4'), - exp_month=getattr(stripe_payment_method, stripe_payment_method.type, {}).get('exp_month'), - exp_year=getattr(stripe_payment_method, stripe_payment_method.type, {}).get('exp_year'), - ) + + # Get any active subscriptions that might need payment confirmation + subscriptions = stripe.Subscription.list(customer=customer_id, status='active', limit=1) + + requires_action = False + client_secret = None + payment_intent_status = None + + # Check if there's a subscription with pending payment that requires action + if subscriptions.data: + subscription = subscriptions.data[0] + if subscription.latest_invoice: + invoice = stripe.Invoice.retrieve( + subscription.latest_invoice, + expand=['payment_intent'] + ) + + if invoice.payment_intent: + payment_intent = invoice.payment_intent + payment_intent_status = payment_intent.status + + if payment_intent.status in ['requires_action', 'requires_source_action']: + requires_action = True + client_secret = payment_intent.client_secret + logger.info("Payment requires authentication", + customer_id=customer_id, + payment_intent_id=payment_intent.id, + status=payment_intent.status) + + payment_method_details = getattr(stripe_payment_method, stripe_payment_method.type, {}) + + return { + 'payment_method': { + 'id': stripe_payment_method.id, + 'type': stripe_payment_method.type, + 'brand': payment_method_details.get('brand') if isinstance(payment_method_details, dict) else getattr(payment_method_details, 'brand', None), + 'last4': payment_method_details.get('last4') if isinstance(payment_method_details, dict) else getattr(payment_method_details, 'last4', None), + 'exp_month': payment_method_details.get('exp_month') if isinstance(payment_method_details, dict) else getattr(payment_method_details, 'exp_month', None), + 'exp_year': payment_method_details.get('exp_year') if isinstance(payment_method_details, dict) else getattr(payment_method_details, 'exp_year', None), + }, + 'requires_action': requires_action, + 'client_secret': client_secret, + 'payment_intent_status': payment_intent_status + } except stripe.error.StripeError as e: logger.error("Failed to update Stripe payment method", error=str(e)) raise e @@ -253,10 +516,12 @@ class StripeProvider(PaymentProvider): subscription_id=subscription_id) # Handle period dates for trial vs active subscriptions - if stripe_subscription.status == 'trialing' and stripe_subscription.items and stripe_subscription.items.data: - first_item = stripe_subscription.items.data[0] - current_period_start = first_item.current_period_start - current_period_end = first_item.current_period_end + if stripe_subscription.status == 'trialing' and hasattr(stripe_subscription, 'items') and stripe_subscription.items: + items_list = stripe_subscription.items.data if hasattr(stripe_subscription.items, 'data') else stripe_subscription.items + if items_list and len(items_list) > 0: + first_item = items_list[0] if isinstance(items_list, list) else items_list + current_period_start = first_item.current_period_start + current_period_end = first_item.current_period_end else: current_period_start = stripe_subscription.current_period_start current_period_end = stripe_subscription.current_period_end @@ -316,17 +581,22 @@ class StripeProvider(PaymentProvider): # Get the actual plan ID from the subscription items plan_id = subscription_id # Default fallback - if stripe_subscription.items and stripe_subscription.items.data: - plan_id = stripe_subscription.items.data[0].price.id + if hasattr(stripe_subscription, 'items') and stripe_subscription.items: + items_list = stripe_subscription.items.data if hasattr(stripe_subscription.items, 'data') else stripe_subscription.items + if items_list and len(items_list) > 0: + first_item = items_list[0] if isinstance(items_list, list) else items_list + plan_id = first_item.price.id # Handle period dates for trial vs active subscriptions # During trial: current_period_* fields are only in subscription items, not root # After trial: current_period_* fields are at root level - if stripe_subscription.status == 'trialing' and stripe_subscription.items and stripe_subscription.items.data: - # For trial subscriptions, get period from first subscription item - first_item = stripe_subscription.items.data[0] - current_period_start = first_item.current_period_start - current_period_end = first_item.current_period_end + if stripe_subscription.status == 'trialing' and hasattr(stripe_subscription, 'items') and stripe_subscription.items: + items_list = stripe_subscription.items.data if hasattr(stripe_subscription.items, 'data') else stripe_subscription.items + if items_list and len(items_list) > 0: + # For trial subscriptions, get period from first subscription item + first_item = items_list[0] if isinstance(items_list, list) else items_list + current_period_start = first_item.current_period_start + current_period_end = first_item.current_period_end else: # For active subscriptions, get period from root level current_period_start = stripe_subscription.current_period_start @@ -381,9 +651,10 @@ class StripeProvider(PaymentProvider): current_subscription = stripe.Subscription.retrieve(subscription_id) # Build update parameters + items_list = current_subscription.items.data if hasattr(current_subscription.items, 'data') else current_subscription.items update_params = { 'items': [{ - 'id': current_subscription.items.data[0].id, + 'id': items_list[0].id if isinstance(items_list, list) else items_list.id, 'price': new_price_id, }], 'proration_behavior': proration_behavior, @@ -411,12 +682,17 @@ class StripeProvider(PaymentProvider): # Get the actual plan ID from the subscription items plan_id = new_price_id - if updated_subscription.items and updated_subscription.items.data: - plan_id = updated_subscription.items.data[0].price.id + if hasattr(updated_subscription, 'items') and updated_subscription.items: + items_list = updated_subscription.items.data if hasattr(updated_subscription.items, 'data') else updated_subscription.items + if items_list and len(items_list) > 0: + first_item = items_list[0] if isinstance(items_list, list) else items_list + plan_id = first_item.price.id # Handle period dates for trial vs active subscriptions - if updated_subscription.status == 'trialing' and updated_subscription.items and updated_subscription.items.data: - first_item = updated_subscription.items.data[0] + if updated_subscription.status == 'trialing' and hasattr(updated_subscription, 'items') and updated_subscription.items: + items_list = updated_subscription.items.data if hasattr(updated_subscription.items, 'data') else updated_subscription.items + if items_list and len(items_list) > 0: + first_item = items_list[0] if isinstance(items_list, list) else items_list current_period_start = first_item.current_period_start current_period_end = first_item.current_period_end else: @@ -463,10 +739,12 @@ class StripeProvider(PaymentProvider): logger.info("Calculating proration for subscription change", subscription_id=subscription_id, new_price_id=new_price_id) - + # Get current subscription current_subscription = stripe.Subscription.retrieve(subscription_id) - current_price_id = current_subscription.items.data[0].price.id + items_list = current_subscription.items.data if hasattr(current_subscription.items, 'data') else current_subscription.items + first_item = items_list[0] if isinstance(items_list, list) else items_list + current_price_id = first_item.price.id # Get current and new prices current_price = stripe.Price.retrieve(current_price_id) @@ -560,10 +838,12 @@ class StripeProvider(PaymentProvider): logger.info("Changing billing cycle for subscription", subscription_id=subscription_id, new_billing_cycle=new_billing_cycle) - + # Get current subscription current_subscription = stripe.Subscription.retrieve(subscription_id) - current_price_id = current_subscription.items.data[0].price.id + items_list = current_subscription.items.data if hasattr(current_subscription.items, 'data') else current_subscription.items + first_item = items_list[0] if isinstance(items_list, list) else items_list + current_price_id = first_item.price.id # Get current price to determine the plan current_price = stripe.Price.retrieve(current_price_id) @@ -643,7 +923,7 @@ class StripeProvider(PaymentProvider): payment_method=payment_method_id, confirm=True ) - + return { 'id': payment_intent.id, 'client_secret': payment_intent.client_secret, @@ -652,3 +932,90 @@ class StripeProvider(PaymentProvider): except stripe.error.StripeError as e: logger.error("Failed to create Stripe payment intent", error=str(e)) raise e + + async def get_customer_payment_method(self, customer_id: str) -> Optional[PaymentMethod]: + """ + Get the default payment method for a customer from Stripe + + Args: + customer_id: Stripe customer ID + + Returns: + PaymentMethod object or None if no payment method exists + """ + try: + logger.info("Retrieving customer payment method", customer_id=customer_id) + + # Retrieve the customer to get default payment method + stripe_customer = stripe.Customer.retrieve(customer_id) + + # Get the default payment method ID + default_payment_method_id = None + if stripe_customer.invoice_settings and stripe_customer.invoice_settings.default_payment_method: + default_payment_method_id = stripe_customer.invoice_settings.default_payment_method + elif stripe_customer.default_source: + default_payment_method_id = stripe_customer.default_source + + if not default_payment_method_id: + logger.info("No default payment method found for customer", customer_id=customer_id) + return None + + # Retrieve the payment method details + stripe_payment_method = stripe.PaymentMethod.retrieve(default_payment_method_id) + + # Extract payment method details based on type + payment_method_details = getattr(stripe_payment_method, stripe_payment_method.type, {}) + + logger.info("Customer payment method retrieved successfully", + customer_id=customer_id, + payment_method_id=stripe_payment_method.id, + payment_method_type=stripe_payment_method.type) + + return PaymentMethod( + id=stripe_payment_method.id, + type=stripe_payment_method.type, + brand=payment_method_details.get('brand') if isinstance(payment_method_details, dict) else getattr(payment_method_details, 'brand', None), + last4=payment_method_details.get('last4') if isinstance(payment_method_details, dict) else getattr(payment_method_details, 'last4', None), + exp_month=payment_method_details.get('exp_month') if isinstance(payment_method_details, dict) else getattr(payment_method_details, 'exp_month', None), + exp_year=payment_method_details.get('exp_year') if isinstance(payment_method_details, dict) else getattr(payment_method_details, 'exp_year', None), + ) + + except stripe.error.StripeError as e: + logger.error("Failed to retrieve customer payment method", + error=str(e), + customer_id=customer_id) + raise e + + async def get_setup_intent(self, setup_intent_id: str) -> stripe.SetupIntent: + """ + Retrieve a SetupIntent from Stripe + + Args: + setup_intent_id: SetupIntent ID to retrieve + + Returns: + stripe.SetupIntent object + + Raises: + stripe.error.StripeError: If retrieval fails + """ + try: + logger.info("Retrieving SetupIntent from Stripe", + setup_intent_id=setup_intent_id) + + # Retrieve SetupIntent from Stripe + setup_intent = stripe.SetupIntent.retrieve(setup_intent_id) + + logger.info("SetupIntent retrieved successfully", + setup_intent_id=setup_intent.id, + status=setup_intent.status, + customer_id=setup_intent.customer) + + return setup_intent + + except stripe.error.StripeError as e: + logger.error("Failed to retrieve SetupIntent from Stripe", + error=str(e), + setup_intent_id=setup_intent_id, + error_type=type(e).__name__) + raise e diff --git a/shared/clients/tenant_client.py b/shared/clients/tenant_client.py index 93760708..5a9b0233 100755 --- a/shared/clients/tenant_client.py +++ b/shared/clients/tenant_client.py @@ -622,6 +622,184 @@ class TenantServiceClient(BaseServiceClient): return None + async def create_payment_customer( + self, + user_data: Dict[str, Any], + payment_method_id: Optional[str] = None + ) -> Dict[str, Any]: + """ + Create payment customer (supports pre-user-creation flow) + + This method creates a payment customer without requiring a user_id, + supporting the secure architecture where users are only created after + payment verification. + + Args: + user_data: User data (email, full_name, etc.) + payment_method_id: Optional payment method ID + + Returns: + Dictionary with payment customer creation result + """ + try: + logger.info("Creating payment customer via tenant service", + email=user_data.get('email'), + payment_method_id=payment_method_id) + + # Call tenant service endpoint + result = await self.post( + "/payment-customers/create", + { + "user_data": user_data, + "payment_method_id": payment_method_id + } + ) + + if result and result.get("success"): + logger.info("Payment customer created successfully via tenant service", + email=user_data.get('email'), + payment_customer_id=result.get('payment_customer_id')) + return result + else: + logger.error("Payment customer creation failed via tenant service", + email=user_data.get('email'), + error=result.get('detail') if result else 'No detail provided') + raise Exception("Payment customer creation failed: " + + (result.get('detail') if result else 'Unknown error')) + + except Exception as e: + logger.error("Failed to create payment customer via tenant service", + email=user_data.get('email'), + error=str(e)) + raise + + async def create_registration_payment_setup( + self, + user_data: Dict[str, Any] + ) -> Dict[str, Any]: + """ + Create registration payment setup via tenant service orchestration + + This method calls the tenant service's orchestration endpoint to create + payment customer and SetupIntent in a coordinated workflow. + + Args: + user_data: User data including email, full_name, payment_method_id, etc. + + Returns: + Dictionary with payment setup results including SetupIntent if required + """ + try: + logger.info("Creating registration payment setup via tenant service orchestration", + email=user_data.get('email'), + payment_method_id=user_data.get('payment_method_id')) + + # Call tenant service orchestration endpoint + result = await self.post( + "/payment-customers/create", + user_data + ) + + if result and result.get("success"): + logger.info("Registration payment setup completed via tenant service orchestration", + email=user_data.get('email'), + requires_action=result.get('requires_action')) + return result + else: + logger.error("Registration payment setup failed via tenant service orchestration", + email=user_data.get('email'), + error=result.get('detail') if result else 'No detail provided') + raise Exception("Registration payment setup failed: " + + (result.get('detail') if result else 'Unknown error')) + + except Exception as e: + logger.error("Failed to create registration payment setup via tenant service orchestration", + email=user_data.get('email'), + error=str(e)) + raise + + async def verify_setup_intent_for_registration( + self, + setup_intent_id: str + ) -> Dict[str, Any]: + """ + Verify SetupIntent status via tenant service orchestration + + This method calls the tenant service's orchestration endpoint to verify + SetupIntent status before proceeding with user creation. + + Args: + setup_intent_id: SetupIntent ID to verify + + Returns: + Dictionary with SetupIntent verification result + """ + try: + logger.info("Verifying SetupIntent via tenant service orchestration", + setup_intent_id=setup_intent_id) + + # Call tenant service orchestration endpoint + result = await self.get( + f"/setup-intents/{setup_intent_id}/verify" + ) + + if result: + logger.info("SetupIntent verification result from tenant service orchestration", + setup_intent_id=setup_intent_id, + status=result.get('status')) + return result + else: + logger.error("SetupIntent verification failed via tenant service orchestration", + setup_intent_id=setup_intent_id, + error='No result returned') + raise Exception("SetupIntent verification failed: No result returned") + + except Exception as e: + logger.error("Failed to verify SetupIntent via tenant service orchestration", + setup_intent_id=setup_intent_id, + error=str(e)) + raise + + async def verify_setup_intent( + self, + setup_intent_id: str + ) -> Dict[str, Any]: + """ + Verify SetupIntent status with payment provider + + Args: + setup_intent_id: SetupIntent ID to verify + + Returns: + Dictionary with SetupIntent verification result + """ + try: + logger.info("Verifying SetupIntent via tenant service", + setup_intent_id=setup_intent_id) + + # Call tenant service endpoint + result = await self.get( + f"/setup-intents/{setup_intent_id}/verify" + ) + + if result: + logger.info("SetupIntent verification result from tenant service", + setup_intent_id=setup_intent_id, + status=result.get('status')) + return result + else: + logger.error("SetupIntent verification failed via tenant service", + setup_intent_id=setup_intent_id, + error='No result returned') + raise Exception("SetupIntent verification failed: No result returned") + + except Exception as e: + logger.error("Failed to verify SetupIntent via tenant service", + setup_intent_id=setup_intent_id, + error=str(e)) + raise + + # Factory function for dependency injection def create_tenant_client(config: BaseServiceSettings) -> TenantServiceClient: """Create tenant service client instance"""