diff --git a/STRIPE_TESTING_GUIDE.md b/STRIPE_TESTING_GUIDE.md index ca214dfc..c99b4956 100644 --- a/STRIPE_TESTING_GUIDE.md +++ b/STRIPE_TESTING_GUIDE.md @@ -23,6 +23,9 @@ Before you begin testing, ensure you have: - ✅ Database configured and accessible - ✅ Redis instance running (for caching) + +stripe listen --forward-to https://bakery-ia.local/api/v1/webhooks/stripe --skip-verify + --- ## Environment Setup diff --git a/frontend/nginx.conf b/frontend/nginx.conf index 7402f45a..2c5891ea 100644 --- a/frontend/nginx.conf +++ b/frontend/nginx.conf @@ -35,7 +35,16 @@ server { # The frontend makes requests to /api which are routed by the ingress controller # Static assets with aggressive caching (including source maps for debugging) - location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot|map)$ { + location ~* ^/assets/.*\.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot|map)$ { + expires 1y; + add_header Cache-Control "public, immutable"; + add_header Vary Accept-Encoding; + access_log off; + try_files $uri =404; + } + + # Also handle JS and CSS files anywhere in the structure (for dynamic imports) + location ~* \.(js|css)$ { expires 1y; add_header Cache-Control "public, immutable"; add_header Vary Accept-Encoding; diff --git a/frontend/src/api/services/subscription.ts b/frontend/src/api/services/subscription.ts index 3c90b76e..fd4992c9 100644 --- a/frontend/src/api/services/subscription.ts +++ b/frontend/src/api/services/subscription.ts @@ -252,8 +252,12 @@ export class SubscriptionService { return apiClient.get(`/tenants/${tenantId}/subscription/validate-upgrade/${planKey}`); } - async upgradePlan(tenantId: string, planKey: string): Promise { - return apiClient.post(`/tenants/${tenantId}/subscription/upgrade`, { new_plan: planKey }); + async upgradePlan(tenantId: string, planKey: string, billingCycle: BillingCycle = 'monthly'): Promise { + // The backend expects new_plan and billing_cycle as query parameters + return apiClient.post( + `/tenants/${tenantId}/subscription/upgrade?new_plan=${planKey}&billing_cycle=${billingCycle}`, + {} + ); } async canAddLocation(tenantId: string): Promise<{ can_add: boolean; reason?: string; current_count?: number; max_allowed?: number }> { diff --git a/frontend/src/api/types/subscription.ts b/frontend/src/api/types/subscription.ts index a7cf9da1..631d085d 100644 --- a/frontend/src/api/types/subscription.ts +++ b/frontend/src/api/types/subscription.ts @@ -261,11 +261,16 @@ export interface PlanUpgradeResult { success: boolean; message: string; new_plan: SubscriptionTier; - effective_date: string; + effective_date?: string; old_plan?: string; new_monthly_price?: number; validation?: any; requires_token_refresh?: boolean; // Backend signals that token should be refreshed + // Trial handling fields + is_trialing?: boolean; + trial_ends_at?: string; + stripe_updated?: boolean; + trial_preserved?: boolean; } export interface SubscriptionInvoice { diff --git a/frontend/src/components/subscription/PaymentMethodUpdateModal.tsx b/frontend/src/components/subscription/PaymentMethodUpdateModal.tsx index 5e995be3..e5a5e202 100644 --- a/frontend/src/components/subscription/PaymentMethodUpdateModal.tsx +++ b/frontend/src/components/subscription/PaymentMethodUpdateModal.tsx @@ -5,6 +5,13 @@ import { DialogModal } from '../../components/ui/DialogModal/DialogModal'; import { subscriptionService } from '../../api'; import { showToast } from '../../utils/toast'; import { useTranslation } from 'react-i18next'; +import { loadStripe, Stripe, StripeElements } from '@stripe/stripe-js'; +import { + Elements, + CardElement, + useStripe as useStripeHook, + useElements as useElementsHook +} from '@stripe/react-stripe-js'; interface PaymentMethodUpdateModalProps { isOpen: boolean; @@ -24,6 +31,277 @@ interface PaymentMethodUpdateModalProps { }) => void; } +// Get Stripe publishable key from environment with proper fallback +const getStripePublishableKey = (): string => { + // Try runtime config first (for production) + if (typeof window !== 'undefined' && (window as any).__RUNTIME_CONFIG__?.VITE_STRIPE_PUBLISHABLE_KEY) { + return (window as any).__RUNTIME_CONFIG__.VITE_STRIPE_PUBLISHABLE_KEY; + } + + // Try build-time env variable + if (import.meta.env.VITE_STRIPE_PUBLISHABLE_KEY) { + return import.meta.env.VITE_STRIPE_PUBLISHABLE_KEY; + } + + // Fallback for development - should not be used in production + console.warn('Stripe publishable key not found in environment. Using test key for development.'); + return 'pk_test_51QuxKyIzCdnBmAVTGM8fvXYkItrBUILz6lHYwhAva6ZAH1HRi0e8zDRgZ4X3faN0zEABp5RHjCVBmMJL3aKXbaC200fFrSNnPl'; +}; + +// Create Stripe promise for Elements provider +const stripePromise = loadStripe(getStripePublishableKey(), { + betas: ['elements_v2'], + locale: 'auto', +}); + +/** + * Stripe Card Form Component + * This component handles the actual Stripe card input and submission + */ +const StripeCardForm = ({ + tenantId, + onSuccess, + onError, + onLoading +}: { + tenantId: string; + onSuccess: (paymentMethod: any) => void; + onError: (error: string) => void; + onLoading: (loading: boolean) => void; +}) => { + const stripe = useStripeHook(); + const elements = useElementsHook(); + const [error, setError] = useState(null); + const [loading, setLoading] = useState(false); + const [authenticating, setAuthenticating] = useState(false); + + // Check if elements are ready + const [elementsReady, setElementsReady] = useState(false); + + useEffect(() => { + if (elements) { + const checkElementsReady = async () => { + try { + // Wait a moment for elements to be fully ready + await new Promise(resolve => setTimeout(resolve, 50)); + setElementsReady(true); + } catch (err) { + console.error('Elements not ready:', err); + setError('Payment form not ready. Please try again.'); + } + }; + + checkElementsReady(); + } + }, [elements]); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + + if (!stripe || !elements || !elementsReady) { + setError('Payment processor not loaded'); + onError('Payment processor not loaded'); + return; + } + + setLoading(true); + setError(null); + onLoading(true); + + try { + // Trigger form validation and wallet collection + const { error: submitError } = await elements.submit(); + + if (submitError) { + setError(submitError.message || 'Failed to validate payment information'); + setLoading(false); + onLoading(false); + onError(submitError.message || 'Failed to validate payment information'); + return; + } + + // Create payment method using Stripe Elements + const cardElement = elements.getElement(CardElement); + const { paymentMethod, error: stripeError } = await stripe.createPaymentMethod({ + type: 'card', + card: cardElement, + }); + + if (stripeError) { + setError(stripeError.message || 'Failed to create payment method'); + setLoading(false); + onLoading(false); + onError(stripeError.message || 'Failed to create payment method'); + return; + } + + if (!paymentMethod || !paymentMethod.id) { + setError('No payment method created'); + setLoading(false); + onLoading(false); + onError('No payment method created'); + return; + } + + // Call backend to update payment method + const result = await subscriptionService.updatePaymentMethod(tenantId, paymentMethod.id); + + // Handle 3D Secure authentication if required + if (result.requires_action && result.client_secret) { + setAuthenticating(true); + setLoading(false); + + try { + // Handle 3D Secure authentication + const { error: confirmError, setupIntent } = await stripe.confirmCardSetup(result.client_secret); + + if (confirmError) { + setError(confirmError.message || '3D Secure authentication failed'); + setAuthenticating(false); + onLoading(false); + onError(confirmError.message || '3D Secure authentication failed'); + return; + } + + if (setupIntent && setupIntent.status === 'succeeded') { + onSuccess({ + brand: result.brand, + last4: result.last4, + exp_month: result.exp_month, + exp_year: result.exp_year, + }); + } else { + setError('3D Secure authentication completed but payment method not confirmed'); + setAuthenticating(false); + onLoading(false); + onError('3D Secure authentication completed but payment method not confirmed'); + } + } catch (authError) { + console.error('Error during 3D Secure authentication:', authError); + const errorMessage = authError instanceof Error + ? authError.message + : '3D Secure authentication error. Please try again.'; + setError(errorMessage); + setAuthenticating(false); + onLoading(false); + onError(errorMessage); + } + } else if (result.success) { + // No authentication required + onSuccess({ + brand: result.brand, + last4: result.last4, + exp_month: result.exp_month, + exp_year: result.exp_year, + }); + } else { + // Handle different payment intent statuses + let errorMessage = result.message || 'Failed to update payment method'; + if (result.payment_intent_status === 'requires_payment_method') { + errorMessage = 'Your card was declined. Please try a different payment method.'; + } else if (result.payment_intent_status === 'requires_action') { + errorMessage = 'Authentication required but client secret missing. Please try again.'; + } else if (result.payment_intent_status === 'processing') { + errorMessage = 'Payment is processing. Please wait a few minutes and try again.'; + } else if (result.payment_intent_status === 'canceled') { + errorMessage = 'Payment was canceled. Please try again.'; + } + + setError(errorMessage); + setLoading(false); + onLoading(false); + onError(errorMessage); + } + } catch (err) { + console.error('Error updating payment method:', err); + const errorMessage = err instanceof Error + ? err.message + : 'An unexpected error occurred while updating payment method.'; + setError(errorMessage); + setLoading(false); + onLoading(false); + onError(errorMessage); + } + }; + + return ( +
+
+ +
+ {!elementsReady ? ( +
+ + Preparando formulario de pago... +
+ ) : ( + + )} +
+
+ + {/* 3D Secure Authentication Status */} + {authenticating && ( +
+ + Completing secure authentication with your bank... +
+ )} + + {/* Error Display */} + {error && ( +
+ + {error} +
+ )} + + {/* Action Buttons */} +
+ + +
+
+ ); +}; + +/** + * Main Payment Method Update Modal Component + */ export const PaymentMethodUpdateModal: React.FC = ({ isOpen, onClose, @@ -33,271 +311,62 @@ 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); - const [cardElement, setCardElement] = useState(null); - const [error, setError] = useState(null); const [success, setSuccess] = useState(false); + const [error, setError] = useState(null); + const [stripeLoaded, setStripeLoaded] = useState(false); + const [stripeError, setStripeError] = useState(null); - // Load Stripe.js dynamically + const handleSuccess = (paymentMethod: any) => { + setSuccess(true); + showToast.success('Payment method updated successfully'); + onPaymentMethodUpdated(paymentMethod); + + // Close modal after a brief delay to show success message + setTimeout(() => { + handleClose(); + }, 2000); + }; + + const handleError = (errorMessage: string) => { + setError(errorMessage); + }; + + const handleLoading = (isLoading: boolean) => { + setLoading(isLoading); + }; + + // Track Stripe loading status useEffect(() => { if (isOpen) { - const loadStripe = async () => { + setStripeLoaded(false); + setStripeError(null); + + // Check if Stripe is already loaded + const checkStripeLoaded = async () => { try { - // Get Stripe publishable key from runtime config or build-time env - const getStripePublishableKey = () => { - if (typeof window !== 'undefined' && (window as any).__RUNTIME_CONFIG__?.VITE_STRIPE_PUBLISHABLE_KEY) { - return (window as any).__RUNTIME_CONFIG__.VITE_STRIPE_PUBLISHABLE_KEY; - } - return import.meta.env.VITE_STRIPE_PUBLISHABLE_KEY; - }; - - const stripeKey = getStripePublishableKey(); - if (!stripeKey) { - throw new Error('Stripe publishable key not configured'); + // Give Stripe a moment to load + await new Promise(resolve => setTimeout(resolve, 100)); + + // Check if the Stripe promise resolves successfully + const stripe = await stripePromise; + if (stripe) { + setStripeLoaded(true); } - - // Load Stripe.js from CDN - const stripeScript = document.createElement('script'); - stripeScript.src = 'https://js.stripe.com/v3/'; - stripeScript.async = true; - stripeScript.onload = () => { - const stripeInstance = (window as any).Stripe(stripeKey); - setStripe(stripeInstance); - const elementsInstance = stripeInstance.elements(); - setElements(elementsInstance); - }; - document.body.appendChild(stripeScript); } catch (err) { console.error('Failed to load Stripe:', err); - setError('Failed to load payment processor'); + setStripeError('Failed to load payment processor. Please check your connection.'); } }; - - loadStripe(); + + checkStripeLoaded(); } }, [isOpen]); - // Create card element when Stripe and elements are loaded - useEffect(() => { - if (elements && !cardElement) { - const card = elements.create('card', { - style: { - base: { - fontSize: '16px', - color: '#32325d', - '::placeholder': { - color: '#aab7c4', - }, - }, - invalid: { - color: '#fa755a', - iconColor: '#fa755a', - }, - }, - }); - - // Mount the card element - const cardElementContainer = document.getElementById('card-element'); - if (cardElementContainer) { - card.mount(cardElementContainer); - setCardElement(card); - } - } - }, [elements, cardElement]); - - const handleSubmit = async (e: React.FormEvent) => { - e.preventDefault(); - - if (!stripe || !cardElement) { - setError('Payment processor not loaded'); - return; - } - - setLoading(true); - setError(null); - setSuccess(false); - - try { - // Submit the card element to validate all inputs - const { error: submitError } = await elements.submit(); - - if (submitError) { - setError(submitError.message || 'Failed to validate payment information'); - setLoading(false); - return; - } - - // Create payment method using Stripe Elements - const { paymentMethod, error: stripeError } = await stripe.createPaymentMethod({ - type: 'card', - card: cardElement, - }); - - if (stripeError) { - setError(stripeError.message || 'Failed to create payment method'); - setLoading(false); - return; - } - - if (!paymentMethod || !paymentMethod.id) { - setError('No payment method created'); - setLoading(false); - return; - } - - // Call backend to update payment method - const result = await subscriptionService.updatePaymentMethod(tenantId, paymentMethod.id); - - // 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 - }); - } - - 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 { - // 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) { - console.error('Error updating payment method:', err); - setError(err instanceof Error ? err.message : 'An unexpected error occurred'); - } finally { - setLoading(false); - } - }; - const handleClose = () => { setError(null); setSuccess(false); - setPaymentMethodId(''); + setStripeLoaded(false); + setStripeError(null); onClose(); }; @@ -306,73 +375,66 @@ export const PaymentMethodUpdateModal: React.FC = isOpen={isOpen} onClose={handleClose} title="Actualizar Método de Pago" - message={null} type="custom" - size="lg" - > -
- {/* Current Payment Method Info */} - {currentPaymentMethod && ( -
-

Método de Pago Actual

-
- -
-

- {currentPaymentMethod.brand} terminando en {currentPaymentMethod.last4} -

- {currentPaymentMethod.exp_month && currentPaymentMethod.exp_year && ( -

- Expira: {currentPaymentMethod.exp_month}/{currentPaymentMethod.exp_year} + size="md" + message={ +

+ {/* Current Payment Method Info */} + {currentPaymentMethod && ( +
+

Método de Pago Actual

+
+ +
+

+ {currentPaymentMethod.brand} terminando en {currentPaymentMethod.last4}

- )} + {currentPaymentMethod.exp_month && currentPaymentMethod.exp_year && ( +

+ Expira: {currentPaymentMethod.exp_month}/{currentPaymentMethod.exp_year} +

+ )} +
-
- )} + )} - {/* Payment Form */} -
-
- -
- {/* Stripe card element will be mounted here */} - {!cardElement && !error && ( -
- - Cargando procesador de pagos... -
- )} -
-
- - {authenticating && ( -
- - Completing secure authentication with your bank... + {/* Stripe Elements Provider with Card Form */} + {!stripeLoaded && !stripeError && ( +
+ + Cargando procesador de pagos seguro...
)} - {error && ( -
- - {error} - {error.includes('3D Secure') && ( - - )} + {stripeError && ( +
+ + Error loading payment processor. Please refresh the page. +
)} + + {stripeLoaded && stripePromise && ( + + + + )} + {/* Success Message */} {success && (
@@ -380,35 +442,23 @@ export const PaymentMethodUpdateModal: React.FC =
)} -
- - -
- + {/* Error Message (from modal level) */} + {error && ( +
+ + {error} +
+ )} - {/* Security Info */} -
-

- - Tus datos de pago están protegidos y se procesan de forma segura. -

+ {/* Security Info */} +
+

+ + Tus datos de pago están protegidos y se procesan de forma segura. +

+
-
- + } + /> ); -}; \ No newline at end of file +}; diff --git a/frontend/src/pages/app/settings/profile/CommunicationPreferences.tsx b/frontend/src/pages/app/settings/profile/CommunicationPreferences.tsx index b5393fc0..dc277f8a 100644 --- a/frontend/src/pages/app/settings/profile/CommunicationPreferences.tsx +++ b/frontend/src/pages/app/settings/profile/CommunicationPreferences.tsx @@ -64,6 +64,7 @@ interface CommunicationPreferencesProps { onSave: (preferences: NotificationPreferences) => Promise; onReset: () => void; hasChanges: boolean; + onPreferencesChange?: (preferences: NotificationPreferences) => void; } const CommunicationPreferences: React.FC = ({ @@ -144,10 +145,16 @@ const CommunicationPreferences: React.FC = ({ ]; const handlePreferenceChange = (key: keyof NotificationPreferences, value: any) => { - setPreferences(prev => ({ - ...prev, + const newPreferences = { + ...preferences, [key]: value - })); + }; + setPreferences(newPreferences); + + // Notify parent component about changes + if (onPreferencesChange) { + onPreferencesChange(newPreferences); + } }; const handleContactChange = (field: 'email' | 'phone', value: string) => { diff --git a/frontend/src/pages/app/settings/profile/NewProfileSettingsPage.tsx b/frontend/src/pages/app/settings/profile/NewProfileSettingsPage.tsx index 737afc7b..af6bc291 100644 --- a/frontend/src/pages/app/settings/profile/NewProfileSettingsPage.tsx +++ b/frontend/src/pages/app/settings/profile/NewProfileSettingsPage.tsx @@ -1,4 +1,4 @@ -import React, { useState } from 'react'; +import React, { useState, useEffect } from 'react'; import { useTranslation } from 'react-i18next'; import { useNavigate } from 'react-router-dom'; import { @@ -18,9 +18,17 @@ import { AlertCircle, Cookie, ExternalLink, - Check + Check, + ChevronDown, + ChevronUp, + Info, + AlertTriangle, + Star, + Settings, + CheckCircle, + RefreshCw } from 'lucide-react'; -import { Button, Card, Avatar, Input, Select, SettingSection, SettingRow, SettingsSearch } from '../../../../components/ui'; +import { Button, Card, Avatar, Input, Select, SettingSection, SettingRow, SettingsSearch, Badge } from '../../../../components/ui'; import { Tabs, TabsList, TabsTrigger, TabsContent } from '../../../../components/ui/Tabs'; import { PageHeader } from '../../../../components/layout'; import { showToast } from '../../../../utils/toast'; @@ -47,6 +55,294 @@ interface PasswordData { confirmPassword: string; } +// Collapsible Section Component (similar to subscription page) +const CollapsibleSection: React.FC<{ + title: string; + icon: React.ReactNode; + isOpen: boolean; + onToggle: () => void; + children: React.ReactNode; + showAlert?: boolean; + className?: string; +}> = ({ title, icon, isOpen, onToggle, children, showAlert = false, className = '' }) => { + return ( + + + + {isOpen && ( +
+ {children} +
+ )} +
+ ); +}; + +// Profile Completion Status Component +const ProfileCompletionStatus: React.FC<{ + completionPercentage: number; + missingFields: string[]; + onEdit: () => void; +}> = ({ completionPercentage, missingFields, onEdit }) => { + const getStatusColor = () => { + if (completionPercentage >= 90) return 'green'; + if (completionPercentage >= 70) return 'yellow'; + return 'red'; + }; + + 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 ( + +
+
+

+ + Perfil Completado +

+ + {Math.round(completionPercentage)}% Completado + +
+ +
+

Campos Completados

+

+ {Math.round(completionPercentage)}% +

+
+ +
+

Campos Faltantes

+

+ {missingFields.length} +

+
+ +
+ +
+
+ + {missingFields.length > 0 && ( +
+
+
+ +
+
+

+ Campos faltantes: {missingFields.join(', ')} +

+
+
+
+ )} +
+ ); +}; + +// Enhanced Profile Field Component with inline editing +const ProfileField: React.FC<{ + label: string; + value: string; + onChange: (value: string) => void; + error?: string; + disabled?: boolean; + type?: string; + icon?: React.ReactNode; + required?: boolean; + isEditing: boolean; + fieldType?: 'text' | 'email' | 'tel' | 'select'; + options?: { value: string; label: string }[]; +}> = ({ + label, + value, + onChange, + error, + disabled = false, + type = 'text', + icon, + required = false, + isEditing, + fieldType = 'text', + options = [] +}) => { + const [isFocused, setIsFocused] = useState(false); + const [localValue, setLocalValue] = useState(value); + + useEffect(() => { + setLocalValue(value); + }, [value]); + + const handleChange = (e: React.ChangeEvent) => { + const newValue = e.target.value; + setLocalValue(newValue); + onChange(newValue); + }; + + const handleBlur = () => { + setIsFocused(false); + if (localValue !== value) { + // Auto-save logic could go here + } + }; + + if (fieldType === 'select') { + return ( +
+
+ {icon &&
{icon}
} + +
+ {isEditing ? ( +
+ + {error &&

{error}

} +
+ ) : ( +
+

{options.find(opt => opt.value === value)?.label || value}

+
+ )} +
+ ); + } + + return ( +
+
+ {icon &&
{icon}
} + +
+ {isEditing ? ( +
+ setIsFocused(true)} + onBlur={handleBlur} + disabled={disabled} + className={`w-full px-3 py-2 border rounded-lg bg-[var(--bg-primary)] text-[var(--text-primary)] focus:ring-2 focus:ring-[var(--color-primary)] focus:border-transparent transition-all text-sm ${ + error ? 'border-red-500' : isFocused ? 'border-[var(--color-primary)]' : 'border-[var(--border-primary)]' + }`} + /> + {error &&

{error}

} +
+ ) : ( +
+

{value || No establecido}

+
+ )} +
+ ); +}; + +// Password Strength Meter Component +const PasswordStrengthMeter: React.FC<{ password: string }> = ({ password }) => { + const getPasswordStrength = (pwd: string) => { + if (!pwd) return 0; + let strength = 0; + if (pwd.length >= 8) strength += 1; + if (/[A-Z]/.test(pwd)) strength += 1; + if (/[0-9]/.test(pwd)) strength += 1; + if (/[^A-Za-z0-9]/.test(pwd)) strength += 1; + if (pwd.length >= 12) strength += 1; + return Math.min(5, strength); + }; + + const strength = getPasswordStrength(password); + const strengthLabels = ['Muy débil', 'Débil', 'Media', 'Fuerte', 'Muy fuerte']; + const strengthColors = ['bg-red-500', 'bg-orange-500', 'bg-yellow-500', 'bg-green-500', 'bg-green-600']; + + return ( +
+
+ Débil + Fuerte +
+
+ {[1, 2, 3, 4, 5].map((level) => ( +
+ ))} +
+ {password && ( +

= 3 ? 'text-green-600' : strength >= 2 ? 'text-yellow-600' : 'text-red-600'}`}> + {strengthLabels[strength - 1] || 'Muy débil'} +

+ )} +
+ ); +}; + const NewProfileSettingsPage: React.FC = () => { const { t } = useTranslation('settings'); const navigate = useNavigate(); @@ -65,6 +361,12 @@ const NewProfileSettingsPage: React.FC = () => { const [showPasswordForm, setShowPasswordForm] = useState(false); const [searchQuery, setSearchQuery] = useState(''); + // Collapsible section states (similar to subscription page) + const [showPersonalInfo, setShowPersonalInfo] = useState(true); + const [showSecurity, setShowSecurity] = useState(false); + const [showNotifications, setShowNotifications] = useState(false); + const [showPrivacy, setShowPrivacy] = useState(false); + // Export & Delete states const [isExporting, setIsExporting] = useState(false); const [showDeleteModal, setShowDeleteModal] = useState(false); @@ -73,6 +375,14 @@ const NewProfileSettingsPage: React.FC = () => { const [deleteReason, setDeleteReason] = useState(''); const [isDeleting, setIsDeleting] = useState(false); + // Profile completion tracking + const [completionPercentage, setCompletionPercentage] = useState(0); + const [missingFields, setMissingFields] = useState([]); + + // Notification preferences tracking + const [notificationPreferences, setNotificationPreferences] = useState(null); + const [notificationHasChanges, setNotificationHasChanges] = useState(false); + const [profileData, setProfileData] = useState({ first_name: '', last_name: '', @@ -105,6 +415,77 @@ const NewProfileSettingsPage: React.FC = () => { } }, [profile]); + // Calculate profile completion percentage + useEffect(() => { + if (profile) { + const requiredFields = ['first_name', 'last_name', 'email', 'phone', 'language', 'timezone']; + const completedFields = requiredFields.filter(field => { + const value = profile[field as keyof typeof profile]; + return value && value.toString().trim() !== ''; + }); + + const percentage = (completedFields.length / requiredFields.length) * 100; + setCompletionPercentage(percentage); + + const missing = requiredFields.filter(field => { + const value = profile[field as keyof typeof profile]; + return !value || value.toString().trim() === ''; + }); + + setMissingFields(missing.map(field => { + const fieldNames: Record = { + first_name: 'Nombre', + last_name: 'Apellido', + email: 'Email', + phone: 'Teléfono', + language: 'Idioma', + timezone: 'Zona Horaria' + }; + return fieldNames[field] || field; + })); + } + }, [profile]); + + // Track when notification preferences change + const handleNotificationPreferencesChange = (preferences: NotificationPreferences) => { + // Compare with stored preferences to detect changes + if (notificationPreferences) { + const hasChanges = JSON.stringify(preferences) !== JSON.stringify(notificationPreferences); + setNotificationHasChanges(hasChanges); + } else { + // First time setting preferences + setNotificationHasChanges(true); + } + }; + + // Initialize notification preferences from profile data + useEffect(() => { + if (profile?.notification_preferences) { + setNotificationPreferences(profile.notification_preferences); + } else { + // Set default preferences if none exist + const defaultPreferences: NotificationPreferences = { + email_enabled: true, + email_alerts: true, + email_marketing: false, + email_reports: true, + whatsapp_enabled: false, + whatsapp_alerts: false, + whatsapp_reports: false, + push_enabled: true, + push_alerts: true, + push_reports: false, + quiet_hours_start: '22:00', + quiet_hours_end: '08:00', + timezone: profile?.timezone || 'Europe/Madrid', + digest_frequency: 'daily', + max_emails_per_day: 10, + language: profile?.language || 'es' + }; + setNotificationPreferences(defaultPreferences); + } + }, [profile]); + const languageOptions = [ { value: 'es', label: 'Español' }, { value: 'eu', label: 'Euskara' }, @@ -218,13 +599,29 @@ const NewProfileSettingsPage: React.FC = () => { const handleSaveNotificationPreferences = async (preferences: NotificationPreferences) => { try { + setIsLoading(true); await updateProfileMutation.mutateAsync({ - language: preferences.language, - timezone: preferences.timezone, notification_preferences: preferences }); + + // Update the stored preferences to track future changes + setNotificationPreferences(preferences); + setNotificationHasChanges(false); + + showToast.success(t('profile.notifications.save_success', 'Preferencias de notificación guardadas correctamente')); } catch (error) { - throw error; + showToast.error(t('profile.notifications.save_error', 'Error al guardar las preferencias de notificación')); + } finally { + setIsLoading(false); + } + }; + + const handleNotificationReset = () => { + // Reset to initial state + if (notificationPreferences) { + // This would typically reset the form to the saved preferences + // For now, we'll just reset the change tracking + setNotificationHasChanges(false); } }; @@ -284,247 +681,364 @@ const NewProfileSettingsPage: React.FC = () => { if (profileLoading || !profile) { return ( -
+
-
-
- {t('common.loading')} +
+
+ +

{t('common.loading')}

+
); } return ( -
+
- {/* Profile Header */} - -
-
- - -
-
-

- {profileData.first_name} {profileData.last_name} -

-

- - {profileData.email} -

- {user?.role && ( -

- - {user.role} -

- )} -
-
- {t('profile.online')} -
-
-
-
- - {/* Search Bar */} - { + setActiveTab('personal'); + setShowPersonalInfo(true); + setIsEditing(true); + }} /> - {/* Tabs Navigation */} - - - - - {t('profile.tabs.personal')} - - - - {t('profile.tabs.notifications')} - - - - {t('profile.tabs.privacy')} - - + {/* Profile Header Card removed as requested */} - {/* Tab 1: Personal Information */} - + {/* Search Bar removed as requested */} + + {/* NEW: Collapsible Sections (similar to subscription page) */} +
+ {/* Personal Information Section */} + } + isOpen={showPersonalInfo} + onToggle={() => setShowPersonalInfo(!showPersonalInfo)} + showAlert={completionPercentage < 90} + >
- {/* Profile Form */} - } - headerAction={ - !isEditing ? ( + {/* Spacing between content blocks */} +
+ + {/* Enhanced Profile Form with Inline Editing */} +
+
+
+ +
+
+

{t('profile.personal_info')}

+

+ {t('profile.personal_info_description') || 'Your personal information and account details'} +

+
+ {!isEditing ? ( + + ) : ( + <> + + + + )} +
+
+
+ + {/* Real-time validation feedback */} + {isEditing && Object.keys(errors).length > 0 && ( +
+
+ +

+ Por favor, corrige los errores antes de guardar +

+
+
+ )} + + {/* Enhanced Profile Fields with Inline Editing */} +
+ handleInputChange('first_name')({ target: { value } } as any)} + error={errors.first_name} + disabled={isLoading} + icon={} + required + isEditing={isEditing} + /> + + handleInputChange('last_name')({ target: { value } } as any)} + error={errors.last_name} + disabled={isLoading} + icon={} + required + isEditing={isEditing} + /> + + handleInputChange('email')({ target: { value } } as any)} + error={errors.email} + disabled={isLoading} + icon={} + required + isEditing={isEditing} + type="email" + /> + + handleInputChange('phone')({ target: { value } } as any)} + error={errors.phone} + disabled={isLoading} + icon={} + isEditing={isEditing} + type="tel" + /> + + handleSelectChange('language')(value)} + error={errors.language} + disabled={isLoading} + icon={} + isEditing={isEditing} + fieldType="select" + options={languageOptions} + /> + + handleSelectChange('timezone')(value)} + error={errors.timezone} + disabled={isLoading} + icon={} + isEditing={isEditing} + fieldType="select" + options={timezoneOptions} + /> +
+ + {/* Save status indicator */} + {isEditing && ( +
+ {isLoading ? ( + <> + + Guardando cambios... + + ) : ( + <> + + Los cambios se guardarán automáticamente al hacer clic en Guardar + + )} +
+ )} +
+
+
+ + {/* Security Section */} + } + isOpen={showSecurity} + onToggle={() => setShowSecurity(!showSecurity)} + > +
+ {/* Spacing between content blocks */} +
+ +
+
+
+ +
+
+

Manage your password and security settings

+

+ Update your password to keep your account secure +

- ) : ( -
- - -
- ) - } - > -
-
- } - required - /> - - - - } - required - /> - - } - /> - - } - />
- - {/* Security Section */} - } - headerAction={ - - } - > {showPasswordForm && ( -
-
- } - required - /> - - } - required - /> - - } - required - /> +
+ {/* Password Requirements Info */} +
+
+ +
+

+ Requisitos de Contraseña +

+
    +
  • + + Mínimo 8 caracteres +
  • +
  • + + Al menos una mayúscula +
  • +
  • + + Al menos un número +
  • +
  • + + Caracter especial recomendado +
  • +
+
+
+ {/* Enhanced Password Fields */} +
+
+ +
+ + {errors.currentPassword && ( +

{errors.currentPassword}

+ )} +
+
+ +
+ +
+ + {errors.newPassword && ( +

{errors.newPassword}

+ )} +
+ {/* Password Strength Meter */} + +
+ +
+ +
+ + {errors.confirmPassword && ( +

{errors.confirmPassword}

+ )} + {/* Password Match Indicator */} + {passwordData.confirmPassword && passwordData.newPassword && ( +
+ {passwordData.newPassword === passwordData.confirmPassword ? ( + <> + + Las contraseñas coinciden + + ) : ( + <> + + Las contraseñas no coinciden + + )} +
+ )} +
+
+
+ + {/* Enhanced Action Buttons */}
+ + {/* Security Tip */} +
+
+ +

+ Consejo de seguridad: Usa una contraseña única que no utilices en otros servicios. Considera usar un gestor de contraseñas para mayor seguridad. +

+
+
)} - +
- + - {/* Tab 2: Notifications */} - - {}} - hasChanges={false} - /> - - - {/* Tab 3: Privacy & Data */} - + {/* Notifications Section */} + } + isOpen={showNotifications} + onToggle={() => setShowNotifications(!showNotifications)} + >
+ {/* Spacing between content blocks */} +
+ + +
+
+ + {/* Privacy & Data Section */} + } + isOpen={showPrivacy} + onToggle={() => setShowPrivacy(!showPrivacy)} + > +
+ {/* Spacing between content blocks */} +
+ {/* GDPR Rights Information */}
@@ -606,80 +1150,83 @@ const NewProfileSettingsPage: React.FC = () => { {/* Cookie Preferences */} - } - headerAction={ - - } - > -
-
+
+
+
+ +
+
+

{t('profile.privacy.cookie_preferences')}

+

+ Gestiona tus preferencias de cookies +

+ +
+
+
{/* Data Export */} - } - headerAction={ - - } - > -
-
+
+
+
+ +
+
+

{t('profile.privacy.export_data')}

+

+ {t('profile.privacy.export_description')} +

+ +
+
+
{/* Account Deletion */} - } - className="border-red-200 dark:border-red-800" - > -
-
- -
-

- {t('profile.privacy.delete_description')} -

-

- {t('profile.privacy.delete_warning')} -

-
+
+
+
+ +
+
+

{t('profile.privacy.delete_account')}

+

+ {t('profile.privacy.delete_description')} +

+

+ {t('profile.privacy.delete_warning')} +

+
- -
- +
- - + +
- {/* Delete Account Modal */} + {/* Delete Account Modal (updated to match subscription page style) */} {showDeleteModal && (
diff --git a/frontend/src/pages/app/settings/subscription/SubscriptionPage.tsx b/frontend/src/pages/app/settings/subscription/SubscriptionPage.tsx index 27c50d38..6f5abc8b 100644 --- a/frontend/src/pages/app/settings/subscription/SubscriptionPage.tsx +++ b/frontend/src/pages/app/settings/subscription/SubscriptionPage.tsx @@ -335,6 +335,9 @@ const SubscriptionPageRedesign: React.FC = () => { exp_year?: number; } | null>(null); + // Billing cycle state (monthly/yearly) + const [billingCycle, setBillingCycle] = useState<'monthly' | 'yearly'>('monthly'); + // Section visibility states const [showUsage, setShowUsage] = useState(false); const [showBilling, setShowBilling] = useState(false); @@ -439,6 +442,10 @@ const SubscriptionPageRedesign: React.FC = () => { setUsageSummary(usage); setAvailablePlans(plans); + // Initialize billing cycle from current subscription + if (usage.billing_cycle) { + setBillingCycle(usage.billing_cycle); + } } catch (error) { console.error('Error loading subscription data:', error); showToast.error( @@ -470,6 +477,7 @@ const SubscriptionPageRedesign: React.FC = () => { try { setUpgrading(true); + // Step 1: Validate the upgrade const validation = await subscriptionService.validatePlanUpgrade( tenantId, selectedPlan @@ -480,21 +488,46 @@ const SubscriptionPageRedesign: React.FC = () => { return; } - const result = await subscriptionService.upgradePlan(tenantId, selectedPlan); + // Step 2: Execute the upgrade (uses current billing cycle) + const result = await subscriptionService.upgradePlan( + tenantId, + selectedPlan, + billingCycle // Pass the currently selected billing cycle + ); if (result.success) { - showToast.success(result.message); + // Show appropriate success message based on trial status + if (result.is_trialing && result.trial_preserved) { + showToast.success( + `¡Plan actualizado a ${result.new_plan}! Tu período de prueba continúa hasta ${ + result.trial_ends_at ? new Date(result.trial_ends_at).toLocaleDateString('es-ES') : 'su fecha original' + }. Al finalizar, se aplicará el precio del nuevo plan.` + ); + } else { + showToast.success(result.message || `¡Plan actualizado exitosamente a ${result.new_plan}!`); + } + + // Step 3: Invalidate caches to refresh UI subscriptionService.invalidateCache(); + // Step 4: Refresh subscription data from server if (result.requires_token_refresh) { + // Force a fresh fetch of subscription data await subscriptionService.getUsageSummary(tenantId).catch(() => {}); } + // Step 5: Invalidate React Query caches await queryClient.invalidateQueries({ queryKey: ['subscription-usage'] }); await queryClient.invalidateQueries({ queryKey: ['tenant'] }); + await queryClient.invalidateQueries({ queryKey: ['subscription'] }); + + // Step 6: Notify other components about the change notifySubscriptionChanged(); + + // Step 7: Reload subscription data for this page await loadSubscriptionData(); + // Step 8: Close dialog and reset state setUpgradeDialogOpen(false); setSelectedPlan(''); } else { @@ -502,7 +535,8 @@ const SubscriptionPageRedesign: React.FC = () => { } } catch (error) { console.error('Error upgrading plan:', error); - showToast.error('Error al procesar el cambio de plan'); + const errorMessage = error instanceof Error ? error.message : 'Error al procesar el cambio de plan'; + showToast.error(errorMessage); } finally { setUpgrading(false); } @@ -647,9 +681,9 @@ const SubscriptionPageRedesign: React.FC = () => { // Determine if this is a pilot subscription based on characteristics // Pilot subscriptions have extended trial periods (typically 90 days from PILOT2025 coupon) // compared to regular trials (typically 14 days), so we check for longer trial periods - const isPilotSubscription = usageSummary.status === 'trialing' && + const isPilotSubscription = (usageSummary.status === 'trialing' || + (usageSummary.trial_ends_at && new Date(usageSummary.trial_ends_at) > new Date())) && usageSummary.trial_ends_at && - new Date(usageSummary.trial_ends_at) > new Date() && // Check if trial period is longer than typical trial (e.g., > 60 days indicates pilot) (new Date(usageSummary.trial_ends_at).getTime() - new Date().getTime()) > (60 * 24 * 60 * 60 * 1000); // 60+ days @@ -726,6 +760,10 @@ const SubscriptionPageRedesign: React.FC = () => { onToggle={() => setShowUsage(!showUsage)} showAlert={hasHighUsageMetrics} > + + {/* Spacing between content blocks */} +
+
{/* Team & Organization */}
@@ -886,6 +924,10 @@ const SubscriptionPageRedesign: React.FC = () => { isOpen={showBilling} onToggle={() => setShowBilling(!showBilling)} > + + {/* Spacing between content blocks */} +
+
{/* Payment Method */}
@@ -931,8 +973,21 @@ const SubscriptionPageRedesign: React.FC = () => {
-

No hay facturas disponibles

-

Las facturas aparecerán aquí una vez realizados los pagos

+ {(usageSummary?.status === 'trialing' || + (usageSummary?.trial_ends_at && new Date(usageSummary.trial_ends_at) > new Date())) ? ( + <> +

Periodo de prueba activo

+

+ No hay facturas durante el periodo de prueba. Las facturas aparecerán aquí + una vez finalice el periodo de prueba el {usageSummary?.trial_ends_at ? new Date(usageSummary.trial_ends_at).toLocaleDateString('es-ES') : 'próximamente'}. +

+ + ) : ( + <> +

No hay facturas disponibles

+

Las facturas aparecerán aquí una vez realizados los pagos

+ + )}
) : ( @@ -951,21 +1006,32 @@ const SubscriptionPageRedesign: React.FC = () => { {Array.isArray(invoices) && invoices.slice(0, 5).map((invoice) => ( - {new Date(invoice.date).toLocaleDateString('es-ES', { + {invoice.created ? new Date(invoice.created * 1000).toLocaleDateString('es-ES', { day: '2-digit', month: 'short', year: 'numeric' - })} + }) : 'N/A'} - {invoice.description || 'Suscripción'} + {invoice.trial ? ( + + + Periodo de prueba + + ) : (invoice.description || 'Suscripción')} - {subscriptionService.formatPrice(invoice.amount)} + {invoice.amount_due !== undefined ? ( + invoice.trial ? ( + 0,00 € + ) : ( + subscriptionService.formatPrice(invoice.amount_due) + ) + ) : 'N/A'} - - {invoice.status === 'paid' ? 'Pagada' : invoice.status === 'open' ? 'Pendiente' : invoice.status} + + {invoice.trial ? 'Prueba' : invoice.status === 'paid' ? 'Pagada' : invoice.status === 'open' ? 'Pendiente' : invoice.status} @@ -977,7 +1043,7 @@ const SubscriptionPageRedesign: React.FC = () => { className="flex items-center gap-2 mx-auto" > - {invoice.invoice_pdf ? 'PDF' : 'Ver'} + {invoice.invoice_pdf ? 'PDF' : invoice.hosted_invoice_url ? 'Ver' : 'Descargar'} @@ -995,23 +1061,7 @@ const SubscriptionPageRedesign: React.FC = () => { )}
- {/* Support Contact */} -
-
- -
-

- ¿Preguntas sobre tu factura? -

-

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

-
-
-
+
@@ -1022,6 +1072,10 @@ const SubscriptionPageRedesign: React.FC = () => { isOpen={showPlans} onToggle={() => setShowPlans(!showPlans)} > + + {/* Spacing between content blocks */} +
+
{/* Available Plans */}
diff --git a/services/auth/migrations/versions/20251015_2155_510cf1184e0b_add_gdpr_consent_tables.py b/services/auth/migrations/versions/20251015_2155_510cf1184e0b_add_gdpr_consent_tables.py deleted file mode 100644 index 76ad9410..00000000 --- a/services/auth/migrations/versions/20251015_2155_510cf1184e0b_add_gdpr_consent_tables.py +++ /dev/null @@ -1,78 +0,0 @@ -"""add_gdpr_consent_tables - -Revision ID: 510cf1184e0b -Revises: 13327ad46a4d -Create Date: 2025-10-15 21:55:40.584671+02:00 - -""" -from typing import Sequence, Union - -from alembic import op -import sqlalchemy as sa -from sqlalchemy.dialects import postgresql - -# revision identifiers, used by Alembic. -revision: str = '510cf1184e0b' -down_revision: Union[str, None] = '13327ad46a4d' -branch_labels: Union[str, Sequence[str], None] = None -depends_on: Union[str, Sequence[str], None] = None - - -def upgrade() -> None: - # ### commands auto generated by Alembic - please adjust! ### - op.create_table('user_consents', - sa.Column('id', sa.UUID(), nullable=False), - sa.Column('user_id', sa.UUID(), nullable=False), - sa.Column('terms_accepted', sa.Boolean(), nullable=False), - sa.Column('privacy_accepted', sa.Boolean(), nullable=False), - sa.Column('marketing_consent', sa.Boolean(), nullable=False), - sa.Column('analytics_consent', sa.Boolean(), nullable=False), - sa.Column('consent_version', sa.String(length=20), nullable=False), - sa.Column('consent_method', sa.String(length=50), nullable=False), - sa.Column('ip_address', sa.String(length=45), nullable=True), - sa.Column('user_agent', sa.Text(), nullable=True), - sa.Column('terms_text_hash', sa.String(length=64), nullable=True), - sa.Column('privacy_text_hash', sa.String(length=64), nullable=True), - sa.Column('consented_at', sa.DateTime(timezone=True), nullable=False), - sa.Column('withdrawn_at', sa.DateTime(timezone=True), nullable=True), - sa.Column('extra_data', postgresql.JSON(astext_type=sa.Text()), nullable=True), - sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'), - sa.PrimaryKeyConstraint('id') - ) - op.create_index('idx_user_consent_consented_at', 'user_consents', ['consented_at'], unique=False) - op.create_index('idx_user_consent_user_id', 'user_consents', ['user_id'], unique=False) - op.create_index(op.f('ix_user_consents_user_id'), 'user_consents', ['user_id'], unique=False) - op.create_table('consent_history', - sa.Column('id', sa.UUID(), nullable=False), - sa.Column('user_id', sa.UUID(), nullable=False), - sa.Column('consent_id', sa.UUID(), nullable=True), - sa.Column('action', sa.String(length=50), nullable=False), - sa.Column('consent_snapshot', postgresql.JSON(astext_type=sa.Text()), nullable=False), - sa.Column('ip_address', sa.String(length=45), nullable=True), - sa.Column('user_agent', sa.Text(), nullable=True), - sa.Column('consent_method', sa.String(length=50), nullable=True), - sa.Column('created_at', sa.DateTime(timezone=True), nullable=False), - sa.ForeignKeyConstraint(['consent_id'], ['user_consents.id'], ondelete='SET NULL'), - sa.PrimaryKeyConstraint('id') - ) - op.create_index('idx_consent_history_action', 'consent_history', ['action'], unique=False) - op.create_index('idx_consent_history_created_at', 'consent_history', ['created_at'], unique=False) - op.create_index('idx_consent_history_user_id', 'consent_history', ['user_id'], unique=False) - op.create_index(op.f('ix_consent_history_created_at'), 'consent_history', ['created_at'], unique=False) - op.create_index(op.f('ix_consent_history_user_id'), 'consent_history', ['user_id'], unique=False) - # ### end Alembic commands ### - - -def downgrade() -> None: - # ### commands auto generated by Alembic - please adjust! ### - op.drop_index(op.f('ix_consent_history_user_id'), table_name='consent_history') - op.drop_index(op.f('ix_consent_history_created_at'), table_name='consent_history') - op.drop_index('idx_consent_history_user_id', table_name='consent_history') - op.drop_index('idx_consent_history_created_at', table_name='consent_history') - op.drop_index('idx_consent_history_action', table_name='consent_history') - op.drop_table('consent_history') - op.drop_index(op.f('ix_user_consents_user_id'), table_name='user_consents') - op.drop_index('idx_user_consent_user_id', table_name='user_consents') - op.drop_index('idx_user_consent_consented_at', table_name='user_consents') - op.drop_table('user_consents') - # ### end Alembic commands ### diff --git a/services/auth/migrations/versions/20260113_add_payment_columns_to_users.py b/services/auth/migrations/versions/20260113_add_payment_columns_to_users.py deleted file mode 100644 index 96df7289..00000000 --- a/services/auth/migrations/versions/20260113_add_payment_columns_to_users.py +++ /dev/null @@ -1,41 +0,0 @@ -"""add_payment_columns_to_users - -Revision ID: 20260113_add_payment_columns -Revises: 510cf1184e0b -Create Date: 2026-01-13 13:30:00.000000+00:00 - -Add payment_customer_id and default_payment_method_id columns to users table -to support payment integration. -""" -from typing import Sequence, Union - -from alembic import op -import sqlalchemy as sa - -# revision identifiers, used by Alembic. -revision: str = '20260113_add_payment_columns' -down_revision: Union[str, None] = '510cf1184e0b' -branch_labels: Union[str, Sequence[str], None] = None -depends_on: Union[str, Sequence[str], None] = None - - -def upgrade() -> None: - # Add payment_customer_id column - op.add_column('users', - sa.Column('payment_customer_id', sa.String(length=255), nullable=True)) - - # Add default_payment_method_id column - op.add_column('users', - sa.Column('default_payment_method_id', sa.String(length=255), nullable=True)) - - # Create index for payment_customer_id - op.create_index(op.f('ix_users_payment_customer_id'), 'users', ['payment_customer_id'], unique=False) - - -def downgrade() -> None: - # Drop index first - op.drop_index(op.f('ix_users_payment_customer_id'), table_name='users') - - # Drop columns - op.drop_column('users', 'default_payment_method_id') - op.drop_column('users', 'payment_customer_id') \ No newline at end of file diff --git a/services/auth/migrations/versions/20260116_add_password_reset_token_table.py b/services/auth/migrations/versions/20260116_add_password_reset_token_table.py deleted file mode 100644 index 88b240bc..00000000 --- a/services/auth/migrations/versions/20260116_add_password_reset_token_table.py +++ /dev/null @@ -1,50 +0,0 @@ -"""Add password reset token table - -Revision ID: 20260116_add_password_reset_token_table -Revises: 20260113_add_payment_columns_to_users.py -Create Date: 2026-01-16 10:00:00.000000 - -""" -from alembic import op -import sqlalchemy as sa -from sqlalchemy.dialects import postgresql - -# revision identifiers -revision = '20260116_add_password_reset_token_table' -down_revision = '20260113_add_payment_columns_to_users.py' -branch_labels = None -depends_on = None - - -def upgrade() -> None: - # Create password_reset_tokens table - op.create_table( - 'password_reset_tokens', - sa.Column('id', postgresql.UUID(as_uuid=True), nullable=False), - sa.Column('user_id', postgresql.UUID(as_uuid=True), nullable=False), - sa.Column('token', sa.String(length=255), nullable=False), - sa.Column('expires_at', sa.DateTime(timezone=True), nullable=False), - sa.Column('is_used', sa.Boolean(), nullable=False, default=False), - sa.Column('created_at', sa.DateTime(timezone=True), nullable=False, - server_default=sa.text("timezone('utc', CURRENT_TIMESTAMP)")), - sa.Column('used_at', sa.DateTime(timezone=True), nullable=True), - sa.PrimaryKeyConstraint('id'), - sa.UniqueConstraint('token'), - ) - - # Create indexes - op.create_index('ix_password_reset_tokens_user_id', 'password_reset_tokens', ['user_id']) - op.create_index('ix_password_reset_tokens_token', 'password_reset_tokens', ['token']) - op.create_index('ix_password_reset_tokens_expires_at', 'password_reset_tokens', ['expires_at']) - op.create_index('ix_password_reset_tokens_is_used', 'password_reset_tokens', ['is_used']) - - -def downgrade() -> None: - # Drop indexes - op.drop_index('ix_password_reset_tokens_is_used', table_name='password_reset_tokens') - op.drop_index('ix_password_reset_tokens_expires_at', table_name='password_reset_tokens') - op.drop_index('ix_password_reset_tokens_token', table_name='password_reset_tokens') - op.drop_index('ix_password_reset_tokens_user_id', table_name='password_reset_tokens') - - # Drop table - op.drop_table('password_reset_tokens') \ No newline at end of file diff --git a/services/auth/migrations/versions/20251015_1229_13327ad46a4d_initial_schema_20251015_1229.py b/services/auth/migrations/versions/initial_schema_unified.py similarity index 59% rename from services/auth/migrations/versions/20251015_1229_13327ad46a4d_initial_schema_20251015_1229.py rename to services/auth/migrations/versions/initial_schema_unified.py index e52cbd2d..af5ab506 100644 --- a/services/auth/migrations/versions/20251015_1229_13327ad46a4d_initial_schema_20251015_1229.py +++ b/services/auth/migrations/versions/initial_schema_unified.py @@ -1,8 +1,15 @@ -"""initial_schema_20251015_1229 +"""Unified initial schema for auth service -Revision ID: 13327ad46a4d +This migration combines all previous migrations into a single initial schema: +- Initial tables (users, refresh_tokens, login_attempts, audit_logs, onboarding) +- GDPR consent tables (user_consents, consent_history) +- Payment columns added to users table +- Password reset tokens table +- Tenant ID made nullable in audit logs + +Revision ID: initial_unified Revises: -Create Date: 2025-10-15 12:29:13.886996+02:00 +Create Date: 2026-01-16 14:00:00.000000 """ from typing import Sequence, Union @@ -12,17 +19,163 @@ import sqlalchemy as sa from sqlalchemy.dialects import postgresql # revision identifiers, used by Alembic. -revision: str = '13327ad46a4d' +revision: str = 'initial_unified' down_revision: Union[str, None] = None branch_labels: Union[str, Sequence[str], None] = None depends_on: Union[str, Sequence[str], None] = None def upgrade() -> None: - # ### commands auto generated by Alembic - please adjust! ### + # Create all tables in the correct order (respecting foreign key dependencies) + + # Base tables without dependencies + op.create_table('users', + sa.Column('id', sa.UUID(), nullable=False), + sa.Column('email', sa.String(length=255), nullable=False), + sa.Column('hashed_password', sa.String(length=255), nullable=False), + sa.Column('full_name', sa.String(length=255), nullable=False), + sa.Column('is_active', sa.Boolean(), nullable=True), + sa.Column('is_verified', sa.Boolean(), nullable=True), + sa.Column('created_at', sa.DateTime(timezone=True), nullable=True), + sa.Column('updated_at', sa.DateTime(timezone=True), nullable=True), + sa.Column('last_login', sa.DateTime(timezone=True), nullable=True), + sa.Column('phone', sa.String(length=20), nullable=True), + sa.Column('language', sa.String(length=10), nullable=True), + sa.Column('timezone', sa.String(length=50), nullable=True), + sa.Column('role', sa.String(length=20), nullable=False), + # Payment-related columns + sa.Column('payment_customer_id', sa.String(length=255), nullable=True), + sa.Column('default_payment_method_id', sa.String(length=255), nullable=True), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_users_email'), 'users', ['email'], unique=True) + op.create_index(op.f('ix_users_payment_customer_id'), 'users', ['payment_customer_id'], unique=False) + + op.create_table('login_attempts', + sa.Column('id', sa.UUID(), nullable=False), + sa.Column('email', sa.String(length=255), nullable=False), + sa.Column('ip_address', sa.String(length=45), nullable=False), + sa.Column('user_agent', sa.Text(), nullable=True), + sa.Column('success', sa.Boolean(), nullable=True), + sa.Column('failure_reason', sa.String(length=255), nullable=True), + sa.Column('created_at', sa.DateTime(timezone=True), nullable=True), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_login_attempts_email'), 'login_attempts', ['email'], unique=False) + + # Tables that reference users + op.create_table('refresh_tokens', + sa.Column('id', sa.UUID(), nullable=False), + sa.Column('user_id', sa.UUID(), nullable=False), + sa.Column('token', sa.Text(), nullable=False), + sa.Column('token_hash', sa.String(length=255), nullable=True), + sa.Column('expires_at', sa.DateTime(timezone=True), nullable=False), + sa.Column('is_revoked', sa.Boolean(), nullable=False), + sa.Column('created_at', sa.DateTime(timezone=True), nullable=True), + sa.Column('revoked_at', sa.DateTime(timezone=True), nullable=True), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('token_hash') + ) + op.create_index('ix_refresh_tokens_expires_at', 'refresh_tokens', ['expires_at'], unique=False) + op.create_index('ix_refresh_tokens_token_hash', 'refresh_tokens', ['token_hash'], unique=False) + op.create_index(op.f('ix_refresh_tokens_user_id'), 'refresh_tokens', ['user_id'], unique=False) + op.create_index('ix_refresh_tokens_user_id_active', 'refresh_tokens', ['user_id', 'is_revoked'], unique=False) + + op.create_table('user_onboarding_progress', + sa.Column('id', sa.UUID(), nullable=False), + sa.Column('user_id', sa.UUID(), nullable=False), + sa.Column('step_name', sa.String(length=50), nullable=False), + sa.Column('completed', sa.Boolean(), nullable=False), + sa.Column('completed_at', sa.DateTime(timezone=True), nullable=True), + sa.Column('step_data', sa.JSON(), nullable=True), + sa.Column('created_at', sa.DateTime(timezone=True), nullable=True), + sa.Column('updated_at', sa.DateTime(timezone=True), nullable=True), + sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('user_id', 'step_name', name='uq_user_step') + ) + op.create_index(op.f('ix_user_onboarding_progress_user_id'), 'user_onboarding_progress', ['user_id'], unique=False) + + op.create_table('user_onboarding_summary', + sa.Column('id', sa.UUID(), nullable=False), + sa.Column('user_id', sa.UUID(), nullable=False), + sa.Column('current_step', sa.String(length=50), nullable=False), + sa.Column('next_step', sa.String(length=50), nullable=True), + sa.Column('completion_percentage', sa.String(length=50), nullable=True), + sa.Column('fully_completed', sa.Boolean(), nullable=True), + sa.Column('steps_completed_count', sa.String(length=50), nullable=True), + sa.Column('created_at', sa.DateTime(timezone=True), nullable=True), + sa.Column('updated_at', sa.DateTime(timezone=True), nullable=True), + sa.Column('last_activity_at', sa.DateTime(timezone=True), nullable=True), + sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_user_onboarding_summary_user_id'), 'user_onboarding_summary', ['user_id'], unique=True) + + op.create_table('password_reset_tokens', + sa.Column('id', postgresql.UUID(as_uuid=True), nullable=False), + sa.Column('user_id', postgresql.UUID(as_uuid=True), nullable=False), + sa.Column('token', sa.String(length=255), nullable=False), + sa.Column('expires_at', sa.DateTime(timezone=True), nullable=False), + sa.Column('is_used', sa.Boolean(), nullable=False, default=False), + sa.Column('created_at', sa.DateTime(timezone=True), nullable=False, + server_default=sa.text("timezone('utc', CURRENT_TIMESTAMP)")), + sa.Column('used_at', sa.DateTime(timezone=True), nullable=True), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('token'), + ) + op.create_index('ix_password_reset_tokens_user_id', 'password_reset_tokens', ['user_id']) + op.create_index('ix_password_reset_tokens_token', 'password_reset_tokens', ['token']) + op.create_index('ix_password_reset_tokens_expires_at', 'password_reset_tokens', ['expires_at']) + op.create_index('ix_password_reset_tokens_is_used', 'password_reset_tokens', ['is_used']) + + # GDPR consent tables + op.create_table('user_consents', + sa.Column('id', sa.UUID(), nullable=False), + sa.Column('user_id', sa.UUID(), nullable=False), + sa.Column('terms_accepted', sa.Boolean(), nullable=False), + sa.Column('privacy_accepted', sa.Boolean(), nullable=False), + sa.Column('marketing_consent', sa.Boolean(), nullable=False), + sa.Column('analytics_consent', sa.Boolean(), nullable=False), + sa.Column('consent_version', sa.String(length=20), nullable=False), + sa.Column('consent_method', sa.String(length=50), nullable=False), + sa.Column('ip_address', sa.String(length=45), nullable=True), + sa.Column('user_agent', sa.Text(), nullable=True), + sa.Column('terms_text_hash', sa.String(length=64), nullable=True), + sa.Column('privacy_text_hash', sa.String(length=64), nullable=True), + sa.Column('consented_at', sa.DateTime(timezone=True), nullable=False), + sa.Column('withdrawn_at', sa.DateTime(timezone=True), nullable=True), + sa.Column('extra_data', postgresql.JSON(astext_type=sa.Text()), nullable=True), + sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id') + ) + op.create_index('idx_user_consent_consented_at', 'user_consents', ['consented_at'], unique=False) + op.create_index('idx_user_consent_user_id', 'user_consents', ['user_id'], unique=False) + op.create_index(op.f('ix_user_consents_user_id'), 'user_consents', ['user_id'], unique=False) + + op.create_table('consent_history', + sa.Column('id', sa.UUID(), nullable=False), + sa.Column('user_id', sa.UUID(), nullable=False), + sa.Column('consent_id', sa.UUID(), nullable=True), + sa.Column('action', sa.String(length=50), nullable=False), + sa.Column('consent_snapshot', postgresql.JSON(astext_type=sa.Text()), nullable=False), + sa.Column('ip_address', sa.String(length=45), nullable=True), + sa.Column('user_agent', sa.Text(), nullable=True), + sa.Column('consent_method', sa.String(length=50), nullable=True), + sa.Column('created_at', sa.DateTime(timezone=True), nullable=False), + sa.ForeignKeyConstraint(['consent_id'], ['user_consents.id'], ondelete='SET NULL'), + sa.PrimaryKeyConstraint('id') + ) + op.create_index('idx_consent_history_action', 'consent_history', ['action'], unique=False) + op.create_index('idx_consent_history_created_at', 'consent_history', ['created_at'], unique=False) + op.create_index('idx_consent_history_user_id', 'consent_history', ['user_id'], unique=False) + op.create_index(op.f('ix_consent_history_created_at'), 'consent_history', ['created_at'], unique=False) + op.create_index(op.f('ix_consent_history_user_id'), 'consent_history', ['user_id'], unique=False) + + # Audit logs table (with tenant_id nullable as per the last migration) op.create_table('audit_logs', sa.Column('id', sa.UUID(), nullable=False), - sa.Column('tenant_id', sa.UUID(), nullable=False), + sa.Column('tenant_id', sa.UUID(), nullable=True), # Made nullable per last migration sa.Column('user_id', sa.UUID(), nullable=False), sa.Column('action', sa.String(length=100), nullable=False), sa.Column('resource_type', sa.String(length=100), nullable=False), @@ -52,97 +205,10 @@ def upgrade() -> None: op.create_index(op.f('ix_audit_logs_severity'), 'audit_logs', ['severity'], unique=False) op.create_index(op.f('ix_audit_logs_tenant_id'), 'audit_logs', ['tenant_id'], unique=False) op.create_index(op.f('ix_audit_logs_user_id'), 'audit_logs', ['user_id'], unique=False) - op.create_table('login_attempts', - sa.Column('id', sa.UUID(), nullable=False), - sa.Column('email', sa.String(length=255), nullable=False), - sa.Column('ip_address', sa.String(length=45), nullable=False), - sa.Column('user_agent', sa.Text(), nullable=True), - sa.Column('success', sa.Boolean(), nullable=True), - sa.Column('failure_reason', sa.String(length=255), nullable=True), - sa.Column('created_at', sa.DateTime(timezone=True), nullable=True), - sa.PrimaryKeyConstraint('id') - ) - op.create_index(op.f('ix_login_attempts_email'), 'login_attempts', ['email'], unique=False) - op.create_table('refresh_tokens', - sa.Column('id', sa.UUID(), nullable=False), - sa.Column('user_id', sa.UUID(), nullable=False), - sa.Column('token', sa.Text(), nullable=False), - sa.Column('token_hash', sa.String(length=255), nullable=True), - sa.Column('expires_at', sa.DateTime(timezone=True), nullable=False), - sa.Column('is_revoked', sa.Boolean(), nullable=False), - sa.Column('created_at', sa.DateTime(timezone=True), nullable=True), - sa.Column('revoked_at', sa.DateTime(timezone=True), nullable=True), - sa.PrimaryKeyConstraint('id'), - sa.UniqueConstraint('token_hash') - ) - op.create_index('ix_refresh_tokens_expires_at', 'refresh_tokens', ['expires_at'], unique=False) - op.create_index('ix_refresh_tokens_token_hash', 'refresh_tokens', ['token_hash'], unique=False) - op.create_index(op.f('ix_refresh_tokens_user_id'), 'refresh_tokens', ['user_id'], unique=False) - op.create_index('ix_refresh_tokens_user_id_active', 'refresh_tokens', ['user_id', 'is_revoked'], unique=False) - op.create_table('users', - sa.Column('id', sa.UUID(), nullable=False), - sa.Column('email', sa.String(length=255), nullable=False), - sa.Column('hashed_password', sa.String(length=255), nullable=False), - sa.Column('full_name', sa.String(length=255), nullable=False), - sa.Column('is_active', sa.Boolean(), nullable=True), - sa.Column('is_verified', sa.Boolean(), nullable=True), - sa.Column('created_at', sa.DateTime(timezone=True), nullable=True), - sa.Column('updated_at', sa.DateTime(timezone=True), nullable=True), - sa.Column('last_login', sa.DateTime(timezone=True), nullable=True), - sa.Column('phone', sa.String(length=20), nullable=True), - sa.Column('language', sa.String(length=10), nullable=True), - sa.Column('timezone', sa.String(length=50), nullable=True), - sa.Column('role', sa.String(length=20), nullable=False), - sa.PrimaryKeyConstraint('id') - ) - op.create_index(op.f('ix_users_email'), 'users', ['email'], unique=True) - op.create_table('user_onboarding_progress', - sa.Column('id', sa.UUID(), nullable=False), - sa.Column('user_id', sa.UUID(), nullable=False), - sa.Column('step_name', sa.String(length=50), nullable=False), - sa.Column('completed', sa.Boolean(), nullable=False), - sa.Column('completed_at', sa.DateTime(timezone=True), nullable=True), - sa.Column('step_data', sa.JSON(), nullable=True), - sa.Column('created_at', sa.DateTime(timezone=True), nullable=True), - sa.Column('updated_at', sa.DateTime(timezone=True), nullable=True), - sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'), - sa.PrimaryKeyConstraint('id'), - sa.UniqueConstraint('user_id', 'step_name', name='uq_user_step') - ) - op.create_index(op.f('ix_user_onboarding_progress_user_id'), 'user_onboarding_progress', ['user_id'], unique=False) - op.create_table('user_onboarding_summary', - sa.Column('id', sa.UUID(), nullable=False), - sa.Column('user_id', sa.UUID(), nullable=False), - sa.Column('current_step', sa.String(length=50), nullable=False), - sa.Column('next_step', sa.String(length=50), nullable=True), - sa.Column('completion_percentage', sa.String(length=50), nullable=True), - sa.Column('fully_completed', sa.Boolean(), nullable=True), - sa.Column('steps_completed_count', sa.String(length=50), nullable=True), - sa.Column('created_at', sa.DateTime(timezone=True), nullable=True), - sa.Column('updated_at', sa.DateTime(timezone=True), nullable=True), - sa.Column('last_activity_at', sa.DateTime(timezone=True), nullable=True), - sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'), - sa.PrimaryKeyConstraint('id') - ) - op.create_index(op.f('ix_user_onboarding_summary_user_id'), 'user_onboarding_summary', ['user_id'], unique=True) - # ### end Alembic commands ### def downgrade() -> None: - # ### commands auto generated by Alembic - please adjust! ### - op.drop_index(op.f('ix_user_onboarding_summary_user_id'), table_name='user_onboarding_summary') - op.drop_table('user_onboarding_summary') - op.drop_index(op.f('ix_user_onboarding_progress_user_id'), table_name='user_onboarding_progress') - op.drop_table('user_onboarding_progress') - op.drop_index(op.f('ix_users_email'), table_name='users') - op.drop_table('users') - op.drop_index('ix_refresh_tokens_user_id_active', table_name='refresh_tokens') - op.drop_index(op.f('ix_refresh_tokens_user_id'), table_name='refresh_tokens') - op.drop_index('ix_refresh_tokens_token_hash', table_name='refresh_tokens') - op.drop_index('ix_refresh_tokens_expires_at', table_name='refresh_tokens') - op.drop_table('refresh_tokens') - op.drop_index(op.f('ix_login_attempts_email'), table_name='login_attempts') - op.drop_table('login_attempts') + # Drop tables in reverse order (respecting foreign key dependencies) op.drop_index(op.f('ix_audit_logs_user_id'), table_name='audit_logs') op.drop_index(op.f('ix_audit_logs_tenant_id'), table_name='audit_logs') op.drop_index(op.f('ix_audit_logs_severity'), table_name='audit_logs') @@ -157,4 +223,40 @@ def downgrade() -> None: op.drop_index('idx_audit_service_created', table_name='audit_logs') op.drop_index('idx_audit_resource_type_action', table_name='audit_logs') op.drop_table('audit_logs') - # ### end Alembic commands ### + + op.drop_index(op.f('ix_consent_history_user_id'), table_name='consent_history') + op.drop_index(op.f('ix_consent_history_created_at'), table_name='consent_history') + op.drop_index('idx_consent_history_user_id', table_name='consent_history') + op.drop_index('idx_consent_history_created_at', table_name='consent_history') + op.drop_index('idx_consent_history_action', table_name='consent_history') + op.drop_table('consent_history') + + op.drop_index(op.f('ix_user_consents_user_id'), table_name='user_consents') + op.drop_index('idx_user_consent_user_id', table_name='user_consents') + op.drop_index('idx_user_consent_consented_at', table_name='user_consents') + op.drop_table('user_consents') + + op.drop_index('ix_password_reset_tokens_is_used', table_name='password_reset_tokens') + op.drop_index('ix_password_reset_tokens_expires_at', table_name='password_reset_tokens') + op.drop_index('ix_password_reset_tokens_token', table_name='password_reset_tokens') + op.drop_index('ix_password_reset_tokens_user_id', table_name='password_reset_tokens') + op.drop_table('password_reset_tokens') + + op.drop_index(op.f('ix_user_onboarding_summary_user_id'), table_name='user_onboarding_summary') + op.drop_table('user_onboarding_summary') + + op.drop_index(op.f('ix_user_onboarding_progress_user_id'), table_name='user_onboarding_progress') + op.drop_table('user_onboarding_progress') + + op.drop_index('ix_refresh_tokens_user_id_active', table_name='refresh_tokens') + op.drop_index(op.f('ix_refresh_tokens_user_id'), table_name='refresh_tokens') + op.drop_index('ix_refresh_tokens_token_hash', table_name='refresh_tokens') + op.drop_index('ix_refresh_tokens_expires_at', table_name='refresh_tokens') + op.drop_table('refresh_tokens') + + op.drop_index(op.f('ix_login_attempts_email'), table_name='login_attempts') + op.drop_table('login_attempts') + + op.drop_index(op.f('ix_users_payment_customer_id'), table_name='users') + op.drop_index(op.f('ix_users_email'), table_name='users') + op.drop_table('users') \ No newline at end of file diff --git a/services/tenant/app/api/subscription.py b/services/tenant/app/api/subscription.py index 17b1de2c..18d0a013 100644 --- a/services/tenant/app/api/subscription.py +++ b/services/tenant/app/api/subscription.py @@ -737,15 +737,27 @@ async def validate_plan_upgrade( async def upgrade_subscription_plan( tenant_id: str = Path(..., description="Tenant ID"), new_plan: str = Query(..., description="New plan name"), + billing_cycle: str = Query("monthly", description="Billing cycle (monthly/yearly)"), current_user: Dict[str, Any] = Depends(get_current_user_dep), - limit_service: SubscriptionLimitService = Depends(get_subscription_limit_service) + limit_service: SubscriptionLimitService = Depends(get_subscription_limit_service), + orchestration_service: SubscriptionOrchestrationService = Depends(get_subscription_orchestration_service) ) -> Dict[str, Any]: """ Upgrade subscription plan for a tenant - Includes validation, cache invalidation, and token refresh. + This endpoint handles: + - Plan upgrade validation + - Stripe subscription update (preserves trial status if in trial) + - Local database update + - Cache invalidation + - Token refresh for immediate UI update + + Trial handling: + - If user is in trial, they remain in trial after upgrade + - The upgraded tier price will be charged when trial ends """ try: + # Step 1: Validate upgrade eligibility validation = await limit_service.validate_plan_upgrade(tenant_id, new_plan) if not validation.get("can_upgrade", False): raise HTTPException( @@ -768,22 +780,77 @@ async def upgrade_subscription_plan( detail="No active subscription found for this tenant" ) + old_plan = active_subscription.plan + is_trialing = active_subscription.status == 'trialing' + trial_ends_at = active_subscription.trial_ends_at + + logger.info("Starting subscription upgrade", + extra={ + "tenant_id": tenant_id, + "subscription_id": str(active_subscription.id), + "stripe_subscription_id": active_subscription.subscription_id, + "old_plan": old_plan, + "new_plan": new_plan, + "is_trialing": is_trialing, + "trial_ends_at": str(trial_ends_at) if trial_ends_at else None, + "user_id": current_user["user_id"] + }) + + # Step 2: Update Stripe subscription if Stripe subscription ID exists + stripe_updated = False + if active_subscription.subscription_id: + try: + # Use orchestration service to handle Stripe update with trial preservation + upgrade_result = await orchestration_service.orchestrate_plan_upgrade( + tenant_id=tenant_id, + new_plan=new_plan, + proration_behavior="none" if is_trialing else "create_prorations", + immediate_change=not is_trialing, # Don't change billing anchor if trialing + billing_cycle=billing_cycle + ) + stripe_updated = True + logger.info("Stripe subscription updated successfully", + extra={ + "tenant_id": tenant_id, + "stripe_subscription_id": active_subscription.subscription_id, + "upgrade_result": upgrade_result + }) + except Exception as stripe_error: + logger.error("Failed to update Stripe subscription, falling back to local update only", + extra={"tenant_id": tenant_id, "error": str(stripe_error)}) + # Continue with local update even if Stripe fails + # This ensures the user gets access to features immediately + + # Step 3: Update local database updated_subscription = await subscription_repo.update_subscription_plan( str(active_subscription.id), new_plan ) + # Preserve trial status if was trialing + if is_trialing and trial_ends_at: + # Ensure trial_ends_at is preserved after plan update + await subscription_repo.update_subscription_status( + str(active_subscription.id), + 'trialing', + {'trial_ends_at': trial_ends_at} + ) + await session.commit() - logger.info("Subscription plan upgraded successfully", + logger.info("Subscription plan upgraded successfully in database", extra={ "tenant_id": tenant_id, "subscription_id": str(active_subscription.id), - "old_plan": active_subscription.plan, + "old_plan": old_plan, "new_plan": new_plan, + "stripe_updated": stripe_updated, + "preserved_trial": is_trialing, "user_id": current_user["user_id"] }) + # Step 4: Invalidate subscription cache + redis_client = None try: from app.services.subscription_cache import get_subscription_cache_service @@ -797,14 +864,17 @@ async def upgrade_subscription_plan( logger.error("Failed to invalidate subscription cache after upgrade", extra={"tenant_id": tenant_id, "error": str(cache_error)}) + # Step 5: Invalidate tokens for immediate UI refresh try: - await _invalidate_tenant_tokens(tenant_id, redis_client) - logger.info("Invalidated all tokens for tenant after subscription upgrade", - extra={"tenant_id": tenant_id}) + if redis_client: + await _invalidate_tenant_tokens(tenant_id, redis_client) + logger.info("Invalidated all tokens for tenant after subscription upgrade", + extra={"tenant_id": tenant_id}) except Exception as token_error: logger.error("Failed to invalidate tenant tokens after upgrade", extra={"tenant_id": tenant_id, "error": str(token_error)}) + # Step 6: Publish subscription change event for other services try: from shared.messaging import UnifiedEventPublisher event_publisher = UnifiedEventPublisher() @@ -813,9 +883,12 @@ async def upgrade_subscription_plan( tenant_id=tenant_id, data={ "tenant_id": tenant_id, - "old_tier": active_subscription.plan, + "old_tier": old_plan, "new_tier": new_plan, - "action": "upgrade" + "action": "upgrade", + "is_trialing": is_trialing, + "trial_ends_at": trial_ends_at.isoformat() if trial_ends_at else None, + "stripe_updated": stripe_updated } ) logger.info("Published subscription change event", @@ -826,10 +899,13 @@ async def upgrade_subscription_plan( return { "success": True, - "message": f"Plan successfully upgraded to {new_plan}", - "old_plan": active_subscription.plan, + "message": f"Plan successfully upgraded to {new_plan}" + (" (trial preserved)" if is_trialing else ""), + "old_plan": old_plan, "new_plan": new_plan, "new_monthly_price": updated_subscription.monthly_price, + "is_trialing": is_trialing, + "trial_ends_at": trial_ends_at.isoformat() if trial_ends_at else None, + "stripe_updated": stripe_updated, "validation": validation, "requires_token_refresh": True } diff --git a/services/tenant/app/api/tenant_operations.py b/services/tenant/app/api/tenant_operations.py index aa986bdb..f5b30e77 100644 --- a/services/tenant/app/api/tenant_operations.py +++ b/services/tenant/app/api/tenant_operations.py @@ -114,10 +114,12 @@ async def register_bakery( error=str(linking_error)) elif bakery_data.coupon_code: - coupon_validation = payment_service.validate_coupon_code( + from app.services.coupon_service import CouponService + + coupon_service = CouponService(db) + coupon_validation = await coupon_service.validate_coupon_code( bakery_data.coupon_code, - tenant_id, - db + tenant_id ) if not coupon_validation["valid"]: @@ -131,10 +133,10 @@ async def register_bakery( detail=coupon_validation["error_message"] ) - success, discount, error = payment_service.redeem_coupon( + success, discount, error = await coupon_service.redeem_coupon( bakery_data.coupon_code, tenant_id, - db + base_trial_days=0 ) if success: @@ -194,13 +196,15 @@ async def register_bakery( if coupon_validation and coupon_validation["valid"]: from app.core.config import settings + from app.services.coupon_service import CouponService database_manager = create_database_manager(settings.DATABASE_URL, "tenant-service") async with database_manager.get_session() as session: - success, discount, error = payment_service.redeem_coupon( + coupon_service = CouponService(session) + success, discount, error = await coupon_service.redeem_coupon( bakery_data.coupon_code, result.id, - session + base_trial_days=0 ) if success: diff --git a/services/tenant/app/repositories/subscription_repository.py b/services/tenant/app/repositories/subscription_repository.py index f299b608..ec5f2ebf 100644 --- a/services/tenant/app/repositories/subscription_repository.py +++ b/services/tenant/app/repositories/subscription_repository.py @@ -247,6 +247,50 @@ class SubscriptionRepository(TenantBaseRepository): error=str(e)) raise DatabaseError(f"Failed to update plan: {str(e)}") + async def update_subscription_status( + self, + subscription_id: str, + status: str, + additional_data: Dict[str, Any] = None + ) -> Optional[Subscription]: + """Update subscription status with optional additional data""" + try: + # Get subscription to find tenant_id for cache invalidation + subscription = await self.get_by_id(subscription_id) + if not subscription: + raise ValidationError(f"Subscription {subscription_id} not found") + + update_data = { + "status": status, + "updated_at": datetime.utcnow() + } + + # Merge additional data if provided + if additional_data: + update_data.update(additional_data) + + updated_subscription = await self.update(subscription_id, update_data) + + # Invalidate cache + if subscription.tenant_id: + await self._invalidate_cache(str(subscription.tenant_id)) + + logger.info("Subscription status updated", + subscription_id=subscription_id, + new_status=status, + additional_data=additional_data) + + return updated_subscription + + except ValidationError: + raise + except Exception as e: + logger.error("Failed to update subscription status", + subscription_id=subscription_id, + status=status, + error=str(e)) + raise DatabaseError(f"Failed to update subscription status: {str(e)}") + async def cancel_subscription( self, subscription_id: str, diff --git a/services/tenant/app/services/payment_service.py b/services/tenant/app/services/payment_service.py index 14cf7357..35b96452 100644 --- a/services/tenant/app/services/payment_service.py +++ b/services/tenant/app/services/payment_service.py @@ -852,7 +852,8 @@ class PaymentService: proration_behavior: str = "create_prorations", billing_cycle_anchor: Optional[str] = None, payment_behavior: str = "error_if_incomplete", - immediate_change: bool = True + immediate_change: bool = True, + preserve_trial: bool = False ) -> Any: """ Update subscription price (plan upgrade/downgrade) @@ -860,24 +861,33 @@ class PaymentService: Args: subscription_id: Stripe subscription ID new_price_id: New Stripe price ID - proration_behavior: How to handle proration + proration_behavior: How to handle proration ('create_prorations', 'none', 'always_invoice') billing_cycle_anchor: Billing cycle anchor ("now" or "unchanged") payment_behavior: Payment behavior on update immediate_change: Whether to apply changes immediately + preserve_trial: If True, preserves the trial period after upgrade Returns: Updated subscription object with .id, .status, etc. attributes """ try: result = await retry_with_backoff( - lambda: self.stripe_client.update_subscription(subscription_id, new_price_id), + lambda: self.stripe_client.update_subscription( + subscription_id, + new_price_id, + proration_behavior=proration_behavior, + preserve_trial=preserve_trial + ), max_retries=3, exceptions=(SubscriptionCreationFailed,) ) logger.info("Subscription updated successfully", subscription_id=subscription_id, - new_price_id=new_price_id) + new_price_id=new_price_id, + proration_behavior=proration_behavior, + preserve_trial=preserve_trial, + is_trialing=result.get('is_trialing', False)) # Create wrapper object for compatibility with callers expecting .id, .status etc. class SubscriptionWrapper: @@ -887,6 +897,8 @@ class PaymentService: self.current_period_start = data.get('current_period_start') self.current_period_end = data.get('current_period_end') self.customer = data.get('customer_id') + self.trial_end = data.get('trial_end') + self.is_trialing = data.get('is_trialing', False) return SubscriptionWrapper(result) diff --git a/services/tenant/app/services/subscription_orchestration_service.py b/services/tenant/app/services/subscription_orchestration_service.py index 87385fe6..7bfb7252 100644 --- a/services/tenant/app/services/subscription_orchestration_service.py +++ b/services/tenant/app/services/subscription_orchestration_service.py @@ -638,17 +638,22 @@ class SubscriptionOrchestrationService: billing_cycle: str = "monthly" ) -> Dict[str, Any]: """ - Orchestrate plan upgrade workflow with proration + Orchestrate plan upgrade workflow with proration and trial preservation Args: tenant_id: Tenant ID new_plan: New plan name - proration_behavior: Proration behavior + proration_behavior: Proration behavior ('create_prorations', 'none', 'always_invoice') immediate_change: Whether to apply changes immediately billing_cycle: Billing cycle for new plan Returns: Dictionary with upgrade results + + Trial Handling: + - If subscription is in trial, the trial period is preserved + - No proration charges are created during trial + - After trial ends, the user is charged at the new tier price """ try: logger.info("Starting plan upgrade orchestration", @@ -665,33 +670,64 @@ class SubscriptionOrchestrationService: if not subscription.subscription_id: raise ValidationError(f"Tenant {tenant_id} does not have a Stripe subscription ID") + # Step 1.5: Check if subscription is in trial + is_trialing = subscription.status == 'trialing' + trial_ends_at = subscription.trial_ends_at + + logger.info("Subscription trial status", + tenant_id=tenant_id, + is_trialing=is_trialing, + trial_ends_at=str(trial_ends_at) if trial_ends_at else None, + current_plan=subscription.plan) + + # For trial subscriptions: + # - No proration charges (proration_behavior='none') + # - Preserve trial period + # - User gets new tier features immediately + # - User is charged new tier price when trial ends + if is_trialing: + proration_behavior = "none" + logger.info("Trial subscription detected, disabling proration", + tenant_id=tenant_id) + # Step 2: Get Stripe price ID for new plan stripe_price_id = self.payment_service._get_stripe_price_id(new_plan, billing_cycle) - # Step 3: Calculate proration preview - proration_details = await self.payment_service.calculate_payment_proration( - subscription.subscription_id, - stripe_price_id, - proration_behavior - ) + # Step 3: Calculate proration preview (only if not trialing) + proration_details = {} + if not is_trialing: + proration_details = await self.payment_service.calculate_payment_proration( + subscription.subscription_id, + stripe_price_id, + proration_behavior + ) + logger.info("Proration calculated for plan upgrade", + tenant_id=tenant_id, + proration_amount=proration_details.get("net_amount", 0)) + else: + proration_details = { + "subscription_id": subscription.subscription_id, + "new_price_id": stripe_price_id, + "proration_behavior": proration_behavior, + "net_amount": 0, + "trial_preserved": True + } - logger.info("Proration calculated for plan upgrade", - tenant_id=tenant_id, - proration_amount=proration_details.get("net_amount", 0)) - - # Step 4: Update in payment provider + # Step 4: Update in payment provider with trial preservation updated_stripe_subscription = await self.payment_service.update_payment_subscription( subscription.subscription_id, stripe_price_id, proration_behavior=proration_behavior, - billing_cycle_anchor="now" if immediate_change else "unchanged", + billing_cycle_anchor="now" if immediate_change and not is_trialing else "unchanged", payment_behavior="error_if_incomplete", - immediate_change=immediate_change + immediate_change=immediate_change, + preserve_trial=is_trialing # Preserve trial if currently trialing ) logger.info("Plan updated in payment provider", subscription_id=updated_stripe_subscription.id, - new_status=updated_stripe_subscription.status) + new_status=updated_stripe_subscription.status, + is_trialing=getattr(updated_stripe_subscription, 'is_trialing', False)) # Step 5: Update local subscription record update_result = await self.subscription_service.update_subscription_plan_record( @@ -722,8 +758,12 @@ class SubscriptionOrchestrationService: logger.info("Tenant plan information updated", tenant_id=tenant_id) - # Add immediate_change to result + # Add upgrade metadata to result update_result["immediate_change"] = immediate_change + update_result["is_trialing"] = is_trialing + update_result["trial_preserved"] = is_trialing + if trial_ends_at: + update_result["trial_ends_at"] = trial_ends_at.isoformat() return update_result diff --git a/shared/clients/stripe_client.py b/shared/clients/stripe_client.py index 76b3acd2..a1122f73 100755 --- a/shared/clients/stripe_client.py +++ b/shared/clients/stripe_client.py @@ -1324,7 +1324,9 @@ class StripeClient(PaymentProvider): async def update_subscription( self, subscription_id: str, - new_price_id: str + new_price_id: str, + proration_behavior: str = 'create_prorations', + preserve_trial: bool = False ) -> Dict[str, Any]: """ Update subscription price (plan upgrade/downgrade) @@ -1332,35 +1334,69 @@ class StripeClient(PaymentProvider): Args: subscription_id: Subscription ID new_price_id: New price ID + proration_behavior: How to handle proration ('create_prorations', 'none', 'always_invoice') + - 'none': No proration charges (useful during trial) + - 'create_prorations': Create prorated charges (default) + - 'always_invoice': Always create an invoice + preserve_trial: If True, preserves the trial period after upgrade Returns: - Subscription update result + Subscription update result including trial info """ try: subscription = stripe.Subscription.retrieve(subscription_id) - updated_subscription = stripe.Subscription.modify( - subscription_id, - items=[{ + # Check if subscription is currently trialing + is_trialing = subscription.status == 'trialing' + trial_end = subscription.trial_end + + # Build the modification params + modify_params = { + 'items': [{ 'id': subscription['items']['data'][0].id, 'price': new_price_id, }], - proration_behavior='create_prorations', - idempotency_key=f"update_sub_{uuid.uuid4()}" + 'proration_behavior': proration_behavior, + 'idempotency_key': f"update_sub_{uuid.uuid4()}" + } + + # Preserve trial period if requested and currently trialing + # When changing plans during trial, Stripe will by default end the trial + # By explicitly setting trial_end, we preserve it + if preserve_trial and is_trialing and trial_end: + modify_params['trial_end'] = trial_end + logger.info( + "Preserving trial period during upgrade", + extra={ + "subscription_id": subscription_id, + "trial_end": trial_end + } + ) + + updated_subscription = stripe.Subscription.modify( + subscription_id, + **modify_params ) logger.info( "Subscription updated", extra={ "subscription_id": subscription_id, - "new_price_id": new_price_id + "new_price_id": new_price_id, + "proration_behavior": proration_behavior, + "was_trialing": is_trialing, + "new_status": updated_subscription.status } ) return { 'subscription_id': updated_subscription.id, 'status': updated_subscription.status, - 'current_period_end': updated_subscription.current_period_end + 'current_period_start': updated_subscription.current_period_start, + 'current_period_end': updated_subscription.current_period_end, + 'trial_end': updated_subscription.trial_end, + 'is_trialing': updated_subscription.status == 'trialing', + 'customer_id': updated_subscription.customer } except stripe.error.StripeError as e: @@ -1537,6 +1573,13 @@ class StripeClient(PaymentProvider): try: invoices = stripe.Invoice.list(customer=customer_id, limit=10) + # Debug: Log all invoice IDs and amounts to see what Stripe returns + logger.info("stripe_invoices_retrieved", + customer_id=customer_id, + invoice_count=len(invoices.data), + invoice_ids=[inv.id for inv in invoices.data], + invoice_amounts=[inv.amount_due for inv in invoices.data]) + return { 'invoices': [ { @@ -1545,7 +1588,12 @@ class StripeClient(PaymentProvider): 'amount_paid': inv.amount_paid / 100, 'status': inv.status, 'created': inv.created, - 'invoice_pdf': inv.invoice_pdf + 'invoice_pdf': inv.invoice_pdf, + 'hosted_invoice_url': inv.hosted_invoice_url, + 'number': inv.number, + 'currency': inv.currency, + 'description': inv.description, + 'trial': inv.amount_due == 0 # Mark trial invoices } for inv in invoices.data ] diff --git a/shared/service_base.py b/shared/service_base.py index 847fb997..1f9f12e2 100755 --- a/shared/service_base.py +++ b/shared/service_base.py @@ -48,6 +48,10 @@ class BaseFastAPIService: database_manager: Optional[DatabaseManager] = None, expected_tables: Optional[List[str]] = None, custom_health_checks: Optional[Dict[str, Callable[[], Any]]] = None, + redis_enabled: bool = False, + redis_url: Optional[str] = None, + redis_db: int = 0, + redis_max_connections: int = 50, enable_metrics: bool = True, enable_health_checks: bool = True, enable_cors: bool = True, @@ -80,6 +84,14 @@ class BaseFastAPIService: setup_logging(service_name, log_level) self.logger = structlog.get_logger() + # Initialize Redis client if enabled + self.redis_enabled = redis_enabled + self.redis_client = None + self.redis_initialized = False + + if redis_enabled: + self._initialize_redis(redis_url, redis_db, redis_max_connections) + # Will be set during app creation self.app: Optional[FastAPI] = None self.health_manager = None