Add subcription feature 2
This commit is contained in:
@@ -41,6 +41,19 @@ export class AuthService {
|
|||||||
return apiClient.post<UserRegistrationWithSubscriptionResponse>(`${this.baseUrl}/register-with-subscription`, userData);
|
return apiClient.post<UserRegistrationWithSubscriptionResponse>(`${this.baseUrl}/register-with-subscription`, userData);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async completeRegistrationAfterSetupIntent(completionData: {
|
||||||
|
email: string;
|
||||||
|
password: string;
|
||||||
|
full_name: string;
|
||||||
|
setup_intent_id: string;
|
||||||
|
plan_id: string;
|
||||||
|
payment_method_id: string;
|
||||||
|
billing_interval: 'monthly' | 'yearly';
|
||||||
|
coupon_code?: string;
|
||||||
|
}): Promise<TokenResponse> {
|
||||||
|
return apiClient.post<TokenResponse>(`${this.baseUrl}/complete-registration-after-setup-intent`, completionData);
|
||||||
|
}
|
||||||
|
|
||||||
async login(loginData: UserLogin): Promise<TokenResponse> {
|
async login(loginData: UserLogin): Promise<TokenResponse> {
|
||||||
return apiClient.post<TokenResponse>(`${this.baseUrl}/login`, loginData);
|
return apiClient.post<TokenResponse>(`${this.baseUrl}/login`, loginData);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -402,6 +402,26 @@ export class SubscriptionService {
|
|||||||
return apiClient.get(`/subscriptions/${tenantId}/invoices`);
|
return apiClient.get(`/subscriptions/${tenantId}/invoices`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the current payment method for a subscription
|
||||||
|
*/
|
||||||
|
async getCurrentPaymentMethod(
|
||||||
|
tenantId: string
|
||||||
|
): Promise<{
|
||||||
|
brand: string;
|
||||||
|
last4: string;
|
||||||
|
exp_month?: number;
|
||||||
|
exp_year?: number;
|
||||||
|
} | null> {
|
||||||
|
try {
|
||||||
|
const response = await apiClient.get(`/subscriptions/${tenantId}/payment-method`);
|
||||||
|
return response;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to get current payment method:', error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Update the default payment method for a subscription
|
* Update the default payment method for a subscription
|
||||||
*/
|
*/
|
||||||
@@ -416,10 +436,55 @@ export class SubscriptionService {
|
|||||||
last4: string;
|
last4: string;
|
||||||
exp_month?: number;
|
exp_month?: number;
|
||||||
exp_year?: number;
|
exp_year?: number;
|
||||||
|
requires_action?: boolean;
|
||||||
|
client_secret?: string;
|
||||||
|
payment_intent_status?: string;
|
||||||
}> {
|
}> {
|
||||||
return apiClient.post(`/subscriptions/${tenantId}/update-payment-method?payment_method_id=${paymentMethodId}`, {});
|
return apiClient.post(`/subscriptions/${tenantId}/update-payment-method?payment_method_id=${paymentMethodId}`, {});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Complete subscription creation after SetupIntent confirmation
|
||||||
|
*
|
||||||
|
* This method is called after the frontend successfully confirms a SetupIntent
|
||||||
|
* (with or without 3DS authentication). It verifies the SetupIntent and creates
|
||||||
|
* the subscription with the verified payment method.
|
||||||
|
*
|
||||||
|
* @param setupIntentId - The SetupIntent ID that was confirmed by Stripe
|
||||||
|
* @param subscriptionData - Data needed to complete subscription creation
|
||||||
|
* @returns Promise with subscription creation result
|
||||||
|
*/
|
||||||
|
async completeSubscriptionAfterSetupIntent(
|
||||||
|
setupIntentId: string,
|
||||||
|
subscriptionData: {
|
||||||
|
customer_id: string;
|
||||||
|
plan_id: string;
|
||||||
|
payment_method_id: string;
|
||||||
|
trial_period_days?: number;
|
||||||
|
user_id: string;
|
||||||
|
billing_interval: string;
|
||||||
|
}
|
||||||
|
): Promise<{
|
||||||
|
success: boolean;
|
||||||
|
message: string;
|
||||||
|
data: {
|
||||||
|
subscription_id: string;
|
||||||
|
customer_id: string;
|
||||||
|
status: string;
|
||||||
|
plan: string;
|
||||||
|
billing_cycle: string;
|
||||||
|
trial_period_days?: number;
|
||||||
|
current_period_end: string;
|
||||||
|
user_id: string;
|
||||||
|
setup_intent_id: string;
|
||||||
|
};
|
||||||
|
}> {
|
||||||
|
return apiClient.post('/subscriptions/complete-after-setup-intent', {
|
||||||
|
setup_intent_id: setupIntentId,
|
||||||
|
...subscriptionData
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// NEW METHODS - Usage Forecasting & Predictive Analytics
|
// NEW METHODS - Usage Forecasting & Predictive Analytics
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|||||||
@@ -75,6 +75,18 @@ export interface UserRegistration {
|
|||||||
*/
|
*/
|
||||||
export interface UserRegistrationWithSubscriptionResponse extends TokenResponse {
|
export interface UserRegistrationWithSubscriptionResponse extends TokenResponse {
|
||||||
subscription_id?: string | null; // ID of the created subscription (returned if subscription was created during registration)
|
subscription_id?: string | null; // ID of the created subscription (returned if subscription was created during registration)
|
||||||
|
requires_action?: boolean | null; // Whether 3DS/SetupIntent authentication is required
|
||||||
|
action_type?: string | null; // Type of action required (e.g., 'setup_intent_confirmation')
|
||||||
|
client_secret?: string | null; // Client secret for SetupIntent/PaymentIntent authentication
|
||||||
|
payment_intent_id?: string | null; // Payment intent ID (deprecated, use setup_intent_id)
|
||||||
|
setup_intent_id?: string | null; // SetupIntent ID for 3DS authentication
|
||||||
|
customer_id?: string | null; // Stripe customer ID (needed for completion)
|
||||||
|
plan_id?: string | null; // Plan ID (needed for completion)
|
||||||
|
payment_method_id?: string | null; // Payment method ID (needed for completion)
|
||||||
|
trial_period_days?: number | null; // Trial period days (needed for completion)
|
||||||
|
user_id?: string | null; // User ID (needed for completion)
|
||||||
|
billing_interval?: string | null; // Billing interval (needed for completion)
|
||||||
|
message?: string | null; // Message explaining what needs to be done
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -70,6 +70,52 @@ const PaymentForm: React.FC<PaymentFormProps> = ({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Perform client-side validation before sending to Stripe
|
||||||
|
if (!billingDetails.name.trim()) {
|
||||||
|
setError('El nombre del titular es obligatorio');
|
||||||
|
onPaymentError('El nombre del titular es obligatorio');
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!billingDetails.email.trim()) {
|
||||||
|
setError('El correo electrónico es obligatorio');
|
||||||
|
onPaymentError('El correo electrónico es obligatorio');
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Basic email validation
|
||||||
|
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||||
|
if (!emailRegex.test(billingDetails.email)) {
|
||||||
|
setError('Por favor, ingrese un correo electrónico válido');
|
||||||
|
onPaymentError('Por favor, ingrese un correo electrónico válido');
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate required address fields
|
||||||
|
if (!billingDetails.address.line1.trim()) {
|
||||||
|
setError('La dirección es obligatoria');
|
||||||
|
onPaymentError('La dirección es obligatoria');
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!billingDetails.address.city.trim()) {
|
||||||
|
setError('La ciudad es obligatoria');
|
||||||
|
onPaymentError('La ciudad es obligatoria');
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!billingDetails.address.postal_code.trim()) {
|
||||||
|
setError('El código postal es obligatorio');
|
||||||
|
onPaymentError('El código postal es obligatorio');
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
@@ -126,6 +172,16 @@ const PaymentForm: React.FC<PaymentFormProps> = ({
|
|||||||
setCardComplete(event.complete);
|
setCardComplete(event.complete);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Add a cleanup function to ensure loading state is reset when component unmounts
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
// Ensure loading is reset when component unmounts to prevent stuck states
|
||||||
|
if (loading) {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className={`p-6 ${className}`}>
|
<Card className={`p-6 ${className}`}>
|
||||||
<div className="text-center mb-6">
|
<div className="text-center mb-6">
|
||||||
|
|||||||
@@ -75,6 +75,18 @@ export const RegisterForm: React.FC<RegisterFormProps> = ({
|
|||||||
const [errors, setErrors] = useState<Partial<SimpleUserRegistration>>({});
|
const [errors, setErrors] = useState<Partial<SimpleUserRegistration>>({});
|
||||||
const [showPassword, setShowPassword] = useState(false);
|
const [showPassword, setShowPassword] = useState(false);
|
||||||
const [showConfirmPassword, setShowConfirmPassword] = useState(false);
|
const [showConfirmPassword, setShowConfirmPassword] = useState(false);
|
||||||
|
const [loading, setLoading] = useState(false); // Local loading state for 3DS flow
|
||||||
|
|
||||||
|
// State for pending subscription data (needed for post-3DS completion)
|
||||||
|
const [pendingSubscriptionData, setPendingSubscriptionData] = useState<{
|
||||||
|
customer_id: string;
|
||||||
|
plan_id: string;
|
||||||
|
payment_method_id: string;
|
||||||
|
trial_period_days?: number;
|
||||||
|
user_id: string;
|
||||||
|
billing_interval: string;
|
||||||
|
setup_intent_id?: string;
|
||||||
|
} | null>(null);
|
||||||
|
|
||||||
const { register, registerWithSubscription } = useAuthActions();
|
const { register, registerWithSubscription } = useAuthActions();
|
||||||
const isLoading = useAuthLoading();
|
const isLoading = useAuthLoading();
|
||||||
@@ -144,14 +156,76 @@ export const RegisterForm: React.FC<RegisterFormProps> = ({
|
|||||||
// directly to backend via secure API calls.
|
// directly to backend via secure API calls.
|
||||||
|
|
||||||
// Clean up any old registration_progress data on mount (security fix)
|
// Clean up any old registration_progress data on mount (security fix)
|
||||||
|
// Also check for Stripe 3DS redirect parameters
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
try {
|
// Prevent multiple executions of 3DS redirect handling
|
||||||
localStorage.removeItem('registration_progress');
|
const hasProcessedRedirect = sessionStorage.getItem('3ds_redirect_processed');
|
||||||
localStorage.removeItem('wizardState'); // Clean up wizard state too
|
|
||||||
} catch (err) {
|
if (hasProcessedRedirect) {
|
||||||
console.error('Error cleaning up old localStorage data:', err);
|
return;
|
||||||
}
|
}
|
||||||
}, []);
|
|
||||||
|
// Clean up old data
|
||||||
|
localStorage.removeItem('registration_progress');
|
||||||
|
localStorage.removeItem('wizardState');
|
||||||
|
|
||||||
|
// Check if this is a Stripe redirect after 3DS authentication
|
||||||
|
const urlParams = new URLSearchParams(window.location.search);
|
||||||
|
const setupIntent = urlParams.get('setup_intent');
|
||||||
|
const redirectStatus = urlParams.get('redirect_status');
|
||||||
|
|
||||||
|
if (setupIntent && redirectStatus) {
|
||||||
|
console.log('3DS redirect detected:', { setupIntent, redirectStatus });
|
||||||
|
|
||||||
|
// Mark as processed
|
||||||
|
sessionStorage.setItem('3ds_redirect_processed', 'true');
|
||||||
|
|
||||||
|
// Clean the URL immediately
|
||||||
|
const cleanUrl = window.location.pathname;
|
||||||
|
window.history.replaceState({}, '', cleanUrl);
|
||||||
|
|
||||||
|
if (redirectStatus === 'succeeded') {
|
||||||
|
// Retrieve pending registration data (NEW ARCHITECTURE)
|
||||||
|
const pendingDataStr = sessionStorage.getItem('pending_registration_data');
|
||||||
|
|
||||||
|
if (pendingDataStr) {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
const pendingData = JSON.parse(pendingDataStr);
|
||||||
|
console.log('3DS redirect detected - completing registration (NEW ARCHITECTURE)');
|
||||||
|
|
||||||
|
// Complete the registration (create user and subscription)
|
||||||
|
completeRegistrationAfterSetupIntent(setupIntent, pendingData);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error parsing pending registration data:', err);
|
||||||
|
showToast.error('Error al procesar datos de registro', {
|
||||||
|
title: 'Error'
|
||||||
|
});
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.warn('No pending registration data found after 3DS redirect');
|
||||||
|
showToast.error('No se encontraron datos de registro pendientes', {
|
||||||
|
title: 'Error'
|
||||||
|
});
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
} else if (redirectStatus === 'failed') {
|
||||||
|
sessionStorage.removeItem('pending_registration_data');
|
||||||
|
showToast.error('La autenticación 3D Secure ha fallado', {
|
||||||
|
title: 'Error de autenticación'
|
||||||
|
});
|
||||||
|
setCurrentStep('payment');
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear the flag
|
||||||
|
setTimeout(() => {
|
||||||
|
sessionStorage.removeItem('3ds_redirect_processed');
|
||||||
|
}, 1000);
|
||||||
|
}
|
||||||
|
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
|
|
||||||
const validateForm = (): boolean => {
|
const validateForm = (): boolean => {
|
||||||
const newErrors: Partial<SimpleUserRegistration> = {};
|
const newErrors: Partial<SimpleUserRegistration> = {};
|
||||||
@@ -219,31 +293,71 @@ export const RegisterForm: React.FC<RegisterFormProps> = ({
|
|||||||
|
|
||||||
const handleRegistrationSubmit = async (paymentMethodId?: string) => {
|
const handleRegistrationSubmit = async (paymentMethodId?: string) => {
|
||||||
try {
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
const registrationData = {
|
const registrationData = {
|
||||||
full_name: formData.full_name,
|
full_name: formData.full_name,
|
||||||
email: formData.email,
|
email: formData.email,
|
||||||
password: formData.password,
|
password: formData.password,
|
||||||
tenant_name: 'Default Bakery', // Default value since we're not collecting it
|
tenant_name: 'Default Bakery',
|
||||||
subscription_plan: selectedPlan,
|
subscription_plan: selectedPlan,
|
||||||
billing_cycle: billingCycle, // Add billing cycle selection
|
billing_cycle: billingCycle,
|
||||||
payment_method_id: paymentMethodId,
|
payment_method_id: paymentMethodId,
|
||||||
// Include coupon code if pilot customer
|
|
||||||
coupon_code: isPilot ? couponCode : undefined,
|
coupon_code: isPilot ? couponCode : undefined,
|
||||||
// Include consent data
|
|
||||||
terms_accepted: formData.acceptTerms,
|
terms_accepted: formData.acceptTerms,
|
||||||
privacy_accepted: formData.acceptTerms,
|
privacy_accepted: formData.acceptTerms,
|
||||||
marketing_consent: formData.marketingConsent,
|
marketing_consent: formData.marketingConsent,
|
||||||
analytics_consent: formData.analyticsConsent,
|
analytics_consent: formData.analyticsConsent,
|
||||||
// NEW: Include billing address data for subscription creation
|
|
||||||
address: formData.address,
|
address: formData.address,
|
||||||
postal_code: formData.postal_code,
|
postal_code: formData.postal_code,
|
||||||
city: formData.city,
|
city: formData.city,
|
||||||
country: formData.country,
|
country: formData.country,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Use the new registration endpoint with subscription creation
|
// Call registration endpoint
|
||||||
await registerWithSubscription(registrationData);
|
const response = await registerWithSubscription(registrationData);
|
||||||
|
|
||||||
|
// Validate response exists
|
||||||
|
if (!response) {
|
||||||
|
throw new Error('Registration failed: No response from server');
|
||||||
|
}
|
||||||
|
|
||||||
|
// NEW ARCHITECTURE: Check if SetupIntent verification is required
|
||||||
|
if (response.requires_action && response.client_secret) {
|
||||||
|
console.log('SetupIntent verification required (NEW ARCHITECTURE - no user created yet):', {
|
||||||
|
action_type: response.action_type,
|
||||||
|
setup_intent_id: response.setup_intent_id,
|
||||||
|
customer_id: response.customer_id,
|
||||||
|
email: response.email
|
||||||
|
});
|
||||||
|
|
||||||
|
// Store pending registration data for post-3DS completion
|
||||||
|
const pendingData = {
|
||||||
|
email: registrationData.email,
|
||||||
|
password: registrationData.password,
|
||||||
|
full_name: registrationData.full_name,
|
||||||
|
setup_intent_id: response.setup_intent_id!,
|
||||||
|
plan_id: response.plan_id!,
|
||||||
|
payment_method_id: paymentMethodId!,
|
||||||
|
billing_interval: billingCycle,
|
||||||
|
coupon_code: registrationData.coupon_code,
|
||||||
|
customer_id: response.customer_id!,
|
||||||
|
payment_customer_id: response.payment_customer_id!,
|
||||||
|
trial_period_days: response.trial_period_days ?? undefined,
|
||||||
|
client_secret: response.client_secret!
|
||||||
|
};
|
||||||
|
|
||||||
|
setPendingSubscriptionData(pendingData);
|
||||||
|
sessionStorage.setItem('pending_registration_data', JSON.stringify(pendingData));
|
||||||
|
|
||||||
|
setLoading(false);
|
||||||
|
|
||||||
|
// Handle SetupIntent confirmation (with possible 3DS)
|
||||||
|
await handleSetupIntentConfirmation(response.client_secret, paymentMethodId!, pendingData);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Subscription created successfully without 3DS
|
||||||
const successMessage = isPilot
|
const successMessage = isPilot
|
||||||
? '¡Bienvenido al programa piloto! Tu cuenta ha sido creada con 3 meses gratis.'
|
? '¡Bienvenido al programa piloto! Tu cuenta ha sido creada con 3 meses gratis.'
|
||||||
: '¡Bienvenido! Tu cuenta ha sido creada correctamente.';
|
: '¡Bienvenido! Tu cuenta ha sido creada correctamente.';
|
||||||
@@ -251,9 +365,12 @@ export const RegisterForm: React.FC<RegisterFormProps> = ({
|
|||||||
showToast.success(t('auth:register.registering', successMessage), {
|
showToast.success(t('auth:register.registering', successMessage), {
|
||||||
title: t('auth:alerts.success_create', 'Cuenta creada exitosamente')
|
title: t('auth:alerts.success_create', 'Cuenta creada exitosamente')
|
||||||
});
|
});
|
||||||
|
|
||||||
onSuccess?.();
|
onSuccess?.();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
showToast.error(error || t('auth:register.register_button', 'No se pudo crear la account. Verifica que el email no esté en uso.'), {
|
setLoading(false);
|
||||||
|
const errorMessage = err instanceof Error ? err.message : 'No se pudo crear la cuenta';
|
||||||
|
showToast.error(errorMessage, {
|
||||||
title: t('auth:alerts.error_create', 'Error al crear la cuenta')
|
title: t('auth:alerts.error_create', 'Error al crear la cuenta')
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -269,6 +386,141 @@ export const RegisterForm: React.FC<RegisterFormProps> = ({
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Handle SetupIntent confirmation with 3DS support
|
||||||
|
const handleSetupIntentConfirmation = async (
|
||||||
|
clientSecret: string,
|
||||||
|
paymentMethodId: string,
|
||||||
|
pendingData: {
|
||||||
|
email: string;
|
||||||
|
password: string;
|
||||||
|
full_name: string;
|
||||||
|
setup_intent_id: string;
|
||||||
|
plan_id: string;
|
||||||
|
payment_method_id: string;
|
||||||
|
billing_interval: string;
|
||||||
|
coupon_code?: string;
|
||||||
|
customer_id: string;
|
||||||
|
payment_customer_id: string;
|
||||||
|
trial_period_days?: number;
|
||||||
|
client_secret: string;
|
||||||
|
}
|
||||||
|
) => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
const stripeInstance = await stripePromise;
|
||||||
|
|
||||||
|
if (!stripeInstance) {
|
||||||
|
throw new Error('Stripe.js has not loaded correctly.');
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('Confirming SetupIntent (NEW ARCHITECTURE - no user created yet):', { clientSecret });
|
||||||
|
|
||||||
|
// Confirm the SetupIntent with 3DS authentication if needed
|
||||||
|
const returnUrl = `${window.location.origin}${window.location.pathname}`;
|
||||||
|
|
||||||
|
const { error: stripeError, setupIntent } = await stripeInstance.confirmCardSetup(clientSecret, {
|
||||||
|
payment_method: paymentMethodId,
|
||||||
|
return_url: returnUrl
|
||||||
|
});
|
||||||
|
|
||||||
|
if (stripeError) {
|
||||||
|
console.error('SetupIntent confirmation error:', stripeError);
|
||||||
|
showToast.error(stripeError.message || 'Error en la verificación del método de pago', {
|
||||||
|
title: 'Error de autenticación'
|
||||||
|
});
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if setup intent succeeded (no 3DS required, or 3DS completed in modal)
|
||||||
|
if (setupIntent && setupIntent.status === 'succeeded') {
|
||||||
|
console.log('SetupIntent succeeded, completing registration (NEW ARCHITECTURE - creating user now)');
|
||||||
|
await completeRegistrationAfterSetupIntent(setupIntent.id, pendingData);
|
||||||
|
} else if (setupIntent && setupIntent.status === 'requires_action') {
|
||||||
|
// 3DS redirect happened - the completion will be handled in URL redirect handler
|
||||||
|
console.log('SetupIntent requires 3DS redirect, waiting for return...');
|
||||||
|
// Don't set loading to false - keep spinner while redirecting
|
||||||
|
} else {
|
||||||
|
console.error('Unexpected SetupIntent status:', setupIntent?.status);
|
||||||
|
showToast.error(`Estado inesperado: ${setupIntent?.status}`, {
|
||||||
|
title: 'Error'
|
||||||
|
});
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error during SetupIntent confirmation:', err);
|
||||||
|
const errorMessage = err instanceof Error ? err.message : 'Error durante la verificación';
|
||||||
|
showToast.error(errorMessage, {
|
||||||
|
title: 'Error de autenticación'
|
||||||
|
});
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Complete subscription creation after SetupIntent confirmation
|
||||||
|
// Complete registration after SetupIntent confirmation (NEW ARCHITECTURE)
|
||||||
|
const completeRegistrationAfterSetupIntent = async (
|
||||||
|
setupIntentId: string,
|
||||||
|
pendingData: {
|
||||||
|
email: string;
|
||||||
|
password: string;
|
||||||
|
full_name: string;
|
||||||
|
setup_intent_id: string;
|
||||||
|
plan_id: string;
|
||||||
|
payment_method_id: string;
|
||||||
|
billing_interval: string;
|
||||||
|
coupon_code?: string;
|
||||||
|
customer_id: string;
|
||||||
|
payment_customer_id: string;
|
||||||
|
trial_period_days?: number;
|
||||||
|
client_secret: string;
|
||||||
|
}
|
||||||
|
) => {
|
||||||
|
try {
|
||||||
|
console.log('Completing registration after SetupIntent confirmation (NEW ARCHITECTURE - creating user now)');
|
||||||
|
|
||||||
|
// Call backend to complete registration (create user and subscription)
|
||||||
|
const completionData = {
|
||||||
|
email: pendingData.email,
|
||||||
|
password: pendingData.password,
|
||||||
|
full_name: pendingData.full_name,
|
||||||
|
setup_intent_id: setupIntentId,
|
||||||
|
plan_id: pendingData.plan_id,
|
||||||
|
payment_method_id: pendingData.payment_method_id,
|
||||||
|
billing_interval: pendingData.billing_interval,
|
||||||
|
coupon_code: pendingData.coupon_code
|
||||||
|
};
|
||||||
|
|
||||||
|
const response = await completeRegistrationAfterSetupIntent(completionData);
|
||||||
|
|
||||||
|
if (response.access_token) {
|
||||||
|
console.log('Registration completed successfully - user created and authenticated');
|
||||||
|
const successMessage = isPilot
|
||||||
|
? '¡Bienvenido al programa piloto! Tu cuenta ha sido creada con 3 meses gratis.'
|
||||||
|
: '¡Bienvenido! Tu cuenta ha sido creada correctamente.';
|
||||||
|
|
||||||
|
showToast.success(t('auth:register.registering', successMessage), {
|
||||||
|
title: t('auth:alerts.success_create', 'Cuenta creada exitosamente')
|
||||||
|
});
|
||||||
|
|
||||||
|
// Clear pending registration data
|
||||||
|
sessionStorage.removeItem('pending_registration_data');
|
||||||
|
setPendingSubscriptionData(null);
|
||||||
|
|
||||||
|
onSuccess?.();
|
||||||
|
} else {
|
||||||
|
throw new Error('Registration completion failed: No access token received');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
setLoading(false);
|
||||||
|
const errorMessage = err instanceof Error ? err.message : 'Error completando el registro';
|
||||||
|
showToast.error(errorMessage, {
|
||||||
|
title: 'Error de registro'
|
||||||
|
});
|
||||||
|
console.error('Error completing registration:', err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleInputChange = (field: keyof SimpleUserRegistration) => (e: React.ChangeEvent<HTMLInputElement>) => {
|
const handleInputChange = (field: keyof SimpleUserRegistration) => (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
const value = e.target.type === 'checkbox' ? e.target.checked : e.target.value;
|
const value = e.target.type === 'checkbox' ? e.target.checked : e.target.value;
|
||||||
setFormData(prev => ({ ...prev, [field]: value }));
|
setFormData(prev => ({ ...prev, [field]: value }));
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ export const PaymentMethodUpdateModal: React.FC<PaymentMethodUpdateModalProps> =
|
|||||||
}) => {
|
}) => {
|
||||||
const { t } = useTranslation('subscription');
|
const { t } = useTranslation('subscription');
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [authenticating, setAuthenticating] = useState(false);
|
||||||
const [paymentMethodId, setPaymentMethodId] = useState('');
|
const [paymentMethodId, setPaymentMethodId] = useState('');
|
||||||
const [stripe, setStripe] = useState<any>(null);
|
const [stripe, setStripe] = useState<any>(null);
|
||||||
const [elements, setElements] = useState<any>(null);
|
const [elements, setElements] = useState<any>(null);
|
||||||
@@ -141,24 +142,139 @@ export const PaymentMethodUpdateModal: React.FC<PaymentMethodUpdateModalProps> =
|
|||||||
// Call backend to update payment method
|
// Call backend to update payment method
|
||||||
const result = await subscriptionService.updatePaymentMethod(tenantId, paymentMethod.id);
|
const result = await subscriptionService.updatePaymentMethod(tenantId, paymentMethod.id);
|
||||||
|
|
||||||
if (result.success) {
|
// Log 3DS requirement status
|
||||||
setSuccess(true);
|
if (result.requires_action) {
|
||||||
showToast.success(result.message);
|
console.log('3DS authentication required for payment method update', {
|
||||||
|
payment_method_id: paymentMethod.id,
|
||||||
// Notify parent component about the update
|
setup_intent_id: result.payment_intent_id,
|
||||||
onPaymentMethodUpdated({
|
action_required: result.requires_action
|
||||||
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(() => {
|
if (result.success) {
|
||||||
onClose();
|
// Check if 3D Secure authentication is required
|
||||||
}, 2000);
|
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 {
|
} else {
|
||||||
setError(result.message || 'Failed to update payment method');
|
// Handle different payment intent statuses
|
||||||
|
if (result.payment_intent_status === 'requires_payment_method') {
|
||||||
|
setError('Your card was declined. Please try a different payment method.');
|
||||||
|
} else if (result.payment_intent_status === 'requires_action') {
|
||||||
|
setError('Authentication required but client secret missing. Please try again.');
|
||||||
|
} else if (result.payment_intent_status === 'processing') {
|
||||||
|
setError('Payment is processing. Please wait a few minutes and try again.');
|
||||||
|
} else if (result.payment_intent_status === 'canceled') {
|
||||||
|
setError('Payment was canceled. Please try again.');
|
||||||
|
} else {
|
||||||
|
setError(result.message || 'Failed to update payment method');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -223,10 +339,28 @@ export const PaymentMethodUpdateModal: React.FC<PaymentMethodUpdateModalProps> =
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{authenticating && (
|
||||||
|
<div className="p-3 bg-blue-500/10 border border-blue-500/20 rounded-lg flex items-center gap-2 text-blue-500">
|
||||||
|
<CreditCard className="w-4 h-4" />
|
||||||
|
<span className="text-sm">Completing secure authentication with your bank...</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
<div className="p-3 bg-red-500/10 border border-red-500/20 rounded-lg flex items-center gap-2 text-red-500">
|
<div className="p-3 bg-red-500/10 border border-red-500/20 rounded-lg flex items-center gap-2 text-red-500">
|
||||||
<AlertCircle className="w-4 h-4" />
|
<AlertCircle className="w-4 h-4" />
|
||||||
<span className="text-sm">{error}</span>
|
<span className="text-sm">{error}</span>
|
||||||
|
{error.includes('3D Secure') && (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="text"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setError(null)}
|
||||||
|
className="ml-auto text-red-600 hover:text-red-800"
|
||||||
|
>
|
||||||
|
Try Again
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -242,18 +376,18 @@ export const PaymentMethodUpdateModal: React.FC<PaymentMethodUpdateModalProps> =
|
|||||||
type="button"
|
type="button"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={handleClose}
|
onClick={handleClose}
|
||||||
disabled={loading}
|
disabled={loading || authenticating}
|
||||||
>
|
>
|
||||||
Cancelar
|
Cancelar
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
variant="primary"
|
variant="primary"
|
||||||
disabled={loading || !cardElement}
|
disabled={loading || authenticating || !cardElement}
|
||||||
className="flex items-center gap-2"
|
className="flex items-center gap-2"
|
||||||
>
|
>
|
||||||
{loading && <Loader2 className="w-4 h-4 animate-spin" />}
|
{(loading || authenticating) && <Loader2 className="w-4 h-4 animate-spin" />}
|
||||||
{loading ? 'Procesando...' : 'Actualizar Método de Pago'}
|
{authenticating ? 'Autenticando...' : loading ? 'Procesando...' : 'Actualizar Método de Pago'}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -50,7 +50,17 @@
|
|||||||
"next_button": "Siguiente",
|
"next_button": "Siguiente",
|
||||||
"previous_button": "Anterior",
|
"previous_button": "Anterior",
|
||||||
"have_account": "¿Ya tienes una cuenta?",
|
"have_account": "¿Ya tienes una cuenta?",
|
||||||
"sign_in_link": "Iniciar sesión"
|
"sign_in_link": "Iniciar sesión",
|
||||||
|
"3ds_success": "Autenticación 3D Secure completada con éxito",
|
||||||
|
"3ds_failed": "La autenticación 3D Secure ha fallado",
|
||||||
|
"3ds_complete": "Tu tarjeta ha sido verificada. Por favor, inicia sesión.",
|
||||||
|
"3ds_failed_try_again": "La autenticación 3D Secure ha fallado. Por favor, intenta de nuevo con otra tarjeta.",
|
||||||
|
"processing_3ds": "Procesando autenticación",
|
||||||
|
"3ds_error": "Error de autenticación",
|
||||||
|
"3ds_redirect_message": "Tu autenticación 3D Secure se ha completado. Serás redirigido en breve...",
|
||||||
|
"3ds_failed_message": "Hubo un problema con la autenticación 3D Secure. Serás redirigido para intentar de nuevo...",
|
||||||
|
"go_to_login": "Ir a inicio de sesión",
|
||||||
|
"try_again": "Intentar registro de nuevo"
|
||||||
},
|
},
|
||||||
"steps": {
|
"steps": {
|
||||||
"info": "Información",
|
"info": "Información",
|
||||||
|
|||||||
@@ -926,53 +926,296 @@
|
|||||||
"inventoryManagement": {
|
"inventoryManagement": {
|
||||||
"title": "Gestión de Inventario y Control de Stock",
|
"title": "Gestión de Inventario y Control de Stock",
|
||||||
"description": "Control completo de ingredientes con FIFO, alertas de stock bajo y reducción de desperdicios",
|
"description": "Control completo de ingredientes con FIFO, alertas de stock bajo y reducción de desperdicios",
|
||||||
"readTime": "9",
|
"readTime": "15",
|
||||||
"content": {
|
"content": {
|
||||||
"intro": "El inventario rastrea todos tus ingredientes: stock actual, movimientos, proveedores, costos y fechas de caducidad.",
|
"intro": "El sistema de inventario gestiona TODOS tus materiales en una tabla unificada: ingredientes (harina, levadura, mantequilla) Y productos finales (baguettes, croissants). Incluye tracking de lotes, FIFO automático, alertas inteligentes, trazabilidad completa HACCP, valoración de stock y auditoría. Todo integrado con producción, ventas y compras.",
|
||||||
|
"unifiedModel": {
|
||||||
|
"title": "Modelo de Inventario Unificado",
|
||||||
|
"description": "BakeWise usa UN SOLO catálogo para ingredientes y productos finales (no dos tablas separadas). Beneficios:",
|
||||||
|
"benefits": [
|
||||||
|
"Simplificación: Una sola UI para gestionar todo tu inventario (materias primas + productos terminados)",
|
||||||
|
"Transformaciones: Puedes transformar ingredientes en productos (harina + agua → masa madre → baguette) dentro del mismo sistema",
|
||||||
|
"Consistencia: Mismas reglas de stock, caducidad, FIFO aplican a todo",
|
||||||
|
"Flexibilidad: Un ítem puede ser ingrediente para una receta y producto final vendible (ej: masa madre)"
|
||||||
|
],
|
||||||
|
"structure": {
|
||||||
|
"title": "Estructura de Datos",
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"field": "product_type",
|
||||||
|
"values": "INGREDIENT (materia prima) o FINISHED_PRODUCT (producto final)",
|
||||||
|
"example": "Harina T-55 = INGREDIENT, Baguette = FINISHED_PRODUCT"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"field": "ingredient_category",
|
||||||
|
"values": "Para materias primas: FLOURS, DAIRY, YEAST, SUGARS, FATS, SALTS, ADDITIVES, PACKAGING, OTHER",
|
||||||
|
"example": "Harina T-55 → FLOURS, Mantequilla → DAIRY"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"field": "product_category",
|
||||||
|
"values": "Para productos finales: BREAD, PASTRIES, CAKES, SPECIALS, OTHER",
|
||||||
|
"example": "Baguette → BREAD, Croissant → PASTRIES"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"field": "unit_of_measure",
|
||||||
|
"values": "kg, g, L, ml, units (unidades), dozen (docena)",
|
||||||
|
"example": "Harina: kg, Baguettes: units, Leche: L"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"fifoImplementation": {
|
||||||
|
"title": "Sistema FIFO (First-In-First-Out) Automático",
|
||||||
|
"description": "El corazón del control de caducidades. FIFO garantiza que SIEMPRE se usan primero los lotes más antiguos (próximos a caducar).",
|
||||||
|
"howItWorks": [
|
||||||
|
{
|
||||||
|
"step": "1. Almacenamiento por Lotes",
|
||||||
|
"description": "Cada recepción de mercancía crea un Stock (lote) independiente con: batch_number (ej: INV-20260113-001), current_quantity (100 kg), expiration_date (2026-02-15), unit_cost (0.85€/kg), received_date (2026-01-13). Múltiples lotes del mismo ingrediente coexisten en el sistema con fechas de caducidad diferentes."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"step": "2. Reserva FIFO Automática",
|
||||||
|
"description": "Cuando producción necesita consumir ingrediente (ej: 50 kg de harina para 200 baguettes): Sistema ejecuta query SQL: SELECT * FROM stock WHERE ingredient_id = X AND is_available = true ORDER BY expiration_date ASC (más antiguo primero). Reserva lotes secuencialmente empezando por el que caduca antes. Ejemplo: Lote 1 (expira 2026-02-10): 30 kg disponibles → reserva 30 kg, Lote 2 (expira 2026-02-18): 70 kg disponibles → reserva 20 kg (restantes de los 50 kg necesarios). Total consumido: 30 kg del lote antiguo + 20 kg del siguiente lote."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"step": "3. Consumo y Trazabilidad",
|
||||||
|
"description": "El sistema consume stock reservado y registra: StockMovement con movement_type = PRODUCTION_USE, reference_number = BATCH-20260113-005 (batch de producción que consumió), consumed_items array con detalles de cada lote consumido (stock_id, quantity_consumed, batch_number, expiration_date). Progressive tracking: quantity_before, quantity_after por lote. Auditoría completa: quién, cuándo, por qué, desde qué lote."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"step": "4. Actualización de Cantidades",
|
||||||
|
"description": "Para cada lote consumido: current_quantity -= consumed_qty, reserved_quantity -= consumed_qty (libera reserva), available_quantity = current_quantity - reserved_quantity, Si current_quantity = 0 → is_available = false (lote agotado, pero mantiene historial). Sistema nunca borra stocks consumidos (trazabilidad HACCP)."
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"lifoOption": {
|
||||||
|
"title": "LIFO Opcional (Last-In-First-Out)",
|
||||||
|
"description": "En casos especiales puedes usar LIFO (último en entrar, primero en salir): POST /consume-stock?fifo=false. Casos de uso: Ingredientes no perecederos donde quieres rotar antiguo (usar nuevo primero para mantener antiguo como buffer), Productos congelados con shelf life muy largo (años). Por defecto: FIFO=true (99% de los casos en panadería)."
|
||||||
|
},
|
||||||
|
"stateBasedExpiration": {
|
||||||
|
"title": "Fechas de Caducidad State-Dependent",
|
||||||
|
"description": "Innovación clave: productos transformados tienen DOS fechas de caducidad. Original: expiration_date (fecha del ingrediente base, ej: masa par-baked caduca 2026-03-01), Transformada: final_expiration_date (fecha del producto final tras horneado, ej: masa fully-baked caduca 2026-01-18). Sistema usa final_expiration_date si existe, sino expiration_date. Ejemplo real: Masa par-baked shelf life 45 días → Fully baked shelf life 3 días → FIFO usa fecha de 3 días para producto final."
|
||||||
|
}
|
||||||
|
},
|
||||||
"features": [
|
"features": [
|
||||||
{
|
{
|
||||||
"name": "Seguimiento en Tiempo Real",
|
"name": "Seguimiento en Tiempo Real con Múltiples Lotes",
|
||||||
"description": "Stock actualizado automáticamente cuando registras producciones o entregas. Ve el stock actual de cada ingrediente en tiempo real"
|
"description": "Visualización multi-lote en UI: Ingrediente: Harina T-55, Stock Total: 285 kg (suma de todos los lotes activos), Lote 1: 120 kg, expira 2026-02-10, Proveedor A, Lote 2: 95 kg, expira 2026-02-15, Proveedor A, Lote 3: 70 kg, expira 2026-02-20, Proveedor B. Color-coded por urgencia de caducidad: Rojo (expira hoy/mañana), Naranja (2-7 días), Amarillo (8-30 días), Verde (>30 días). Actualización automática: Tras producción batch completado → stock descuenta instantáneamente (WebSocket notify opcional), Tras recepción de mercancía → stock aumenta. Endpoint: GET /api/v1/tenants/{tenant_id}/inventory/stock?ingredient_id={id} → devuelve array de lotes con detalles completos."
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "FIFO Automático",
|
"name": "Alertas de Stock Bajo Multi-Nivel",
|
||||||
"description": "First-In-First-Out (primero en entrar, primero en salir). El sistema consume automáticamente los lotes más antiguos para evitar caducidades"
|
"description": "Sistema de alertas con 2 umbrales configurables por ingrediente: Umbral 1: low_stock_threshold (alerta preventiva, ej: 80 kg). Genera alerta severity='medium', alert_type='low_stock', Acción sugerida: 'Revisar consumo, considerar pedido'. Umbral 2: reorder_point (alerta crítica, ej: 50 kg). Genera alerta severity='high', alert_type='reorder_needed', Acción sugerida: 'HACER PEDIDO URGENTE', Incluye: suggested_order_quantity (cantidad estándar de pedido), supplier_lead_time_days (tiempo de entrega del proveedor), hours_until_stockout (cuántas horas quedan al ritmo actual de consumo). Cálculo inteligente: Sistema analiza consumo últimos 7 días → daily_usage = avg(consumo_diario), days_of_stock = current_stock / daily_usage. Priorización: Alertas ordenadas por hours_until_stockout ASC (primero el más urgente). Notificaciones: Dashboard badge rojo con número de alertas críticas, Email/WhatsApp (si configurado) para alertas CRITICAL, Recomendación de orden de compra auto-generada. Endpoint: GET /api/v1/tenants/{tenant_id}/inventory/stock/low-stock → devuelve array de ingredientes críticos."
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Alertas de Stock Bajo",
|
"name": "Gestión Avanzada de Caducidades",
|
||||||
"description": "Cuando un ingrediente llega al punto de reorden, recibes alerta. Ejemplo: 'Harina T-55: stock 15kg, mínimo 50kg, hacer pedido'"
|
"description": "Sistema multi-stage de alertas de caducidad: CRÍTICO (1-2 días antes): severity='critical', alert_type='urgent_expiry', Acción: 'Usar HOY o marcar como waste', Notificación inmediata. ALTO (3-7 días antes): severity='high', alert_type='expiring_soon', Acción: 'Priorizar uso en producciones', Dashboard alert. MEDIO (8-14 días antes): severity='medium', alert_type='expiring_warning', Acción: 'Planificar consumo', Solo en reportes. Configuración flexible: days_ahead parameter (default: 7, range: 1-365), Personalizable por ingrediente si necesario. Endpoint: GET /api/v1/tenants/{tenant_id}/inventory/stock/expiring?days_ahead=7. Gestión de expirados: Scheduler automático ejecuta diariamente (3 AM): Marca stocks con expiration_date < today como quality_status='expired', Cambia is_available=false (bloquea consumo), Crea alerta inventory.expired_products con lista completa, Genera reporte de waste por caducidad (kg + €). Workflow de waste: Operador revisa expirados en Dashboard → Waste Tracker, Confirma waste → Crea StockMovement type='WASTE' con waste_reason='expired', Stock movido a historial, Métrica de waste actualizada."
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Gestión de Caducidades",
|
"name": "Batch/Lot Management Completo",
|
||||||
"description": "Registra fechas de caducidad de cada lote. Alertas 7 días antes de caducar. Seguimiento de desperdicio por caducidad"
|
"description": "Cada lote (Stock) rastrea: Identificación: batch_number (ej: INV-20260113-001, auto-generado), lot_number (número de lote del proveedor, ej: LOT-2026-A123), supplier_batch_ref (referencia interna del proveedor). Fechas: received_date (cuándo llegó), expiration_date (caducidad original), best_before_date (consumir preferentemente antes de), original_expiration_date (fecha pre-transformación), transformation_date (cuándo se transformó, ej: par-baked → fully baked), final_expiration_date (caducidad post-transformación). Calidad: quality_status (good, damaged, expired, quarantined), Si 'quarantined' → bloqueado para uso hasta inspección/aprobación manager. Almacenaje: storage_location (zona de almacén, estante, ej: 'Zona Fría A, Estante 3'), requires_refrigeration (boolean), requires_freezing (boolean), storage_temperature_min/max (ej: 2-8°C para lácteos), storage_humidity_max (ej: 60% para harinas), shelf_life_days (vida útil en días desde received_date). Costos: unit_cost (€/unidad de medida), total_cost (unit_cost × current_quantity), Actualizado automáticamente al consumir stock. UI BatchModal: Lista de todos los lotes de un ingrediente, Cards colapsables (por UX, empiezan collapsed), Acciones por lote: Edit (cantidad, fecha caducidad, ubicación, calidad), Mark as Waste (convierte a WASTE movement), Delete (admin only, soft delete). Endpoint: GET /api/v1/tenants/{tenant_id}/inventory/stock?ingredient_id={id}."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Transformaciones de Productos (Production Stages)",
|
||||||
|
"description": "Sistema soporta transformar ingredientes a través de etapas de producción: Etapas disponibles: raw_ingredient (comprado del proveedor), prepared_dough (masa preparada pero no horneada), par_baked (pre-horneado), fully_baked (horneado completo), frozen_product (congelado intermedio). Workflow de transformación: Endpoint: POST /api/v1/tenants/{tenant_id}/inventory/transformations, Body: source_ingredient_id (ej: masa par-baked), target_ingredient_id (ej: baguette fully baked), source_quantity (100 unidades par-baked), target_quantity (90 unidades fully baked, 10% loss por merma), expiration_calculation_method: 'days_from_transformation', expiration_days_offset: 3 (baguette fully baked dura 3 días desde horneado). Sistema ejecuta: 1) Reserva source stock usando FIFO, 2) Consume source (crea PRODUCTION_USE movement), 3) Crea nuevo Stock para target con: current_quantity = 90, production_stage = fully_baked, original_expiration_date = fecha del lote par-baked, transformation_date = now(), final_expiration_date = now() + 3 días, 4) Registra Transformation record para trazabilidad. Beneficios: Trazabilidad completa de lote source → target, FIFO usa fecha correcta (3 días final, no 45 días original), Contabilidad precisa de mermas/loss en transformación, Auditoría HACCP (qué lote par-baked generó qué baguettes)."
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"stockEntry": [
|
"stockOperations": {
|
||||||
|
"title": "Operaciones de Stock (Entradas, Salidas, Ajustes)",
|
||||||
|
"movements": [
|
||||||
|
{
|
||||||
|
"type": "PURCHASE (Entrada por Compra)",
|
||||||
|
"description": "Cuando recibes mercancía del proveedor: POST /api/v1/tenants/{tenant_id}/inventory/stock, Body: ingredient_id, current_quantity (ej: 150 kg), unit_cost (ej: 0.85€/kg), expiration_date (opcional, requerido para perecederos), batch_number (auto-generado si no se provee), supplier_id (opcional), production_stage (default: raw_ingredient), quality_status (default: good), storage_location, storage_temperature_min/max (para refrigerados), notes. Sistema ejecuta: Crea Stock record, Genera batch_number único (formato: INV-YYYYMMDD-NNN), Calcula total_cost = current_quantity × unit_cost, Crea StockMovement type='PURCHASE', movement_type='IN', Actualiza weighted_average_cost del ingrediente: WAC_nuevo = (qty_anterior × WAC_anterior + qty_nueva × cost_nuevo) / (qty_anterior + qty_nueva), Actualiza last_purchase_price, Actualiza total stock available. Tiempo: <200ms. UI: AddStockModal con campos dinámicos según tipo de ingrediente."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "PRODUCTION_USE (Salida por Producción)",
|
||||||
|
"description": "Consumo automático cuando batch de producción se completa: Trigger: ProductionBatch status = COMPLETED, Sistema llama: POST /consume-stock?ingredient_id={id}&quantity=50&reference_number=BATCH-001&fifo=true. Sistema ejecuta: Reserva stock usando FIFO (query ORDER BY expiration_date ASC), Consume secuencialmente de lotes más antiguos, Crea StockMovement por cada lote consumido con: movement_type = PRODUCTION_USE, reference_number = BATCH-20260113-005, quantity_before/after por lote (progressive tracking), batch_info (de qué lotes se consumió, cuánto de cada uno), Devuelve: total_quantity_consumed, consumed_items array [{stock_id, quantity_consumed, batch_number, expiration_date}]. Ejemplo response: {'total_quantity_consumed': 50, 'consumed_items': [{'stock_id': '...', 'quantity_consumed': 30, 'batch_number': 'INV-001', 'expiration_date': '2026-02-10'}, {'stock_id': '...', 'quantity_consumed': 20, 'batch_number': 'INV-002', 'expiration_date': '2026-02-15'}]}. Trazabilidad completa: sabes exactamente qué batch de producción consumió qué lotes de ingredientes."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "WASTE (Salida por Desperdicio)",
|
||||||
|
"description": "Registro de merma, caducidad, daño: UI: BatchModal → Select lote → 'Mark as Waste', Modal aparece: waste_quantity (cuánto se desperdicia, puede ser parcial o total del lote), waste_reason (expired, damaged, contaminated, overproduction, quality_issue, other), notes (descripción libre). POST /api/v1/tenants/{tenant_id}/inventory/stock/movements, Body: movement_type='WASTE', stock_id, quantity, waste_reason, notes. Sistema: Descuenta cantidad del lote, Crea StockMovement type='WASTE', Actualiza métricas de waste (kg, €, % del total), Si quantity = total del lote → is_available=false. Reportes: Dashboard → Waste Tracker → waste_by_reason (expired 60%, damaged 25%, overproduction 15%), waste_trend (últimos 30 días), waste_cost (€ perdidos). Objetivo típico: <5% waste rate (waste/total_purchases)."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "TRANSFER (Transferencia entre Ubicaciones)",
|
||||||
|
"description": "Para multi-tenant (obrador + sucursales) o multi-location (almacén → producción): POST /api/v1/tenants/{tenant_id}/inventory/stock/movements, Body: movement_type='TRANSFER', stock_id, quantity, from_location (ej: 'Almacén Central'), to_location (ej: 'Sucursal Centro'), reference_number (ej: TRANSFER-001), notes. Sistema: Descuenta cantidad de from_location stock, Crea nuevo Stock en to_location con misma info (batch_number, expiration_date, unit_cost copiados), Crea 2 StockMovements: OUT en from_location, IN en to_location, Mantiene trazabilidad (mismo batch_number en ambas ubicaciones). Caso de uso: Obrador produce pan → transfiere a 3 sucursales para venta."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "ADJUSTMENT (Ajuste Manual)",
|
||||||
|
"description": "Correcciones por inventario físico, errores de registro, pérdida no identificada: PUT /api/v1/tenants/{tenant_id}/inventory/stock/{stock_id}, Body: current_quantity (nueva cantidad real tras inventario físico), adjustment_reason (physical_inventory, data_error, loss_unidentified, found_extra), notes (explicación). Sistema: Calcula difference = new_quantity - old_quantity, Crea StockMovement type='ADJUSTMENT', movement_type='IN' si difference > 0, 'OUT' si < 0, quantity = abs(difference), Registra quantity_before, quantity_after, user_id (quién hizo ajuste), created_at (timestamp). Auditoría: Todos los ajustes se revisan en Dashboard → Stock History → Filtrar por ADJUSTMENT. Alertas: Si ajuste > 10% del stock → genera alert para manager review (posible error o theft)."
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"inventoryValuation": {
|
||||||
|
"title": "Valoración de Inventario y Contabilidad",
|
||||||
|
"methods": [
|
||||||
|
{
|
||||||
|
"method": "Weighted Average Cost (WAC) - Método Principal",
|
||||||
|
"description": "BakeWise usa WAC (Costo Promedio Ponderado) por defecto. Fórmula: WAC = (Stock_Actual × WAC_Actual + Compra_Nueva × Costo_Nuevo) / (Stock_Actual + Compra_Nueva). Ejemplo: Tienes 100 kg harina @ 0.80€/kg (WAC actual), Compras 50 kg @ 1.00€/kg (compra nueva), Cálculo: WAC_nuevo = (100×0.80 + 50×1.00) / (100+50) = (80 + 50) / 150 = 0.867€/kg. Nuevo stock: 150 kg @ 0.867€/kg. Ventajas WAC: Simple y justo (promedia fluctuaciones de precio), Cumple con normativa contable española (PGC-PYMES), Actualización automática en cada compra. Campos en Ingredient: average_cost (WAC actual, usado para valoración), last_purchase_price (último precio pagado, referencia), standard_cost (coste estándar/presupuestado, opcional para variance analysis)."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "FIFO Costing (Opcional para Reportes)",
|
||||||
|
"description": "Aunque valoración usa WAC, puedes generar reportes con FIFO costing para análisis: Costo FIFO = suma de (qty_consumida_lote_i × unit_cost_lote_i) por orden de consumo FIFO. Útil para: Comparar cost_real vs cost_standard, Identificar impacto de variaciones de precio proveedor, Auditorías que requieren método FIFO. Endpoint: GET /api/v1/tenants/{tenant_id}/inventory/analytics?valuation_method=FIFO (genera reporte con FIFO costing). No afecta contabilidad principal (WAC sigue siendo oficial)."
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"metrics": [
|
||||||
|
{
|
||||||
|
"metric": "Valor Total de Inventario",
|
||||||
|
"calculation": "Σ(current_quantity × average_cost) para todos los stocks activos (is_available=true)",
|
||||||
|
"dashboard": "Dashboard → Inventory Summary → Total Stock Value: 12,450€"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"metric": "Rotación de Inventario (Inventory Turnover)",
|
||||||
|
"calculation": "Turnover = Cost_of_Goods_Sold (COGS) / Average_Inventory_Value. Ejemplo: COGS últimos 12 meses: 120,000€, Inventory promedio: 15,000€, Turnover = 120,000 / 15,000 = 8× al año (renuevas inventario 8 veces/año). Benchmark panadería: 12-20× (productos frescos rotan rápido)",
|
||||||
|
"dashboard": "Dashboard → Analytics → Inventory Turnover: 8.2× (últimos 12 meses)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"metric": "Days of Inventory on Hand (DOH)",
|
||||||
|
"calculation": "DOH = 365 / Turnover. Ejemplo: Turnover 8× → DOH = 365/8 = 45.6 días promedio. Interpretación: En promedio, un ingrediente permanece en inventario 45 días antes de usarse. Objetivo panadería: 15-30 días (frescos), 30-60 días (secos/no perecederos)",
|
||||||
|
"dashboard": "Dashboard → Analytics → Days of Inventory: 45.6 días"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"metric": "Waste Rate (Tasa de Desperdicio)",
|
||||||
|
"calculation": "Waste_Rate = (Total_Waste_kg / Total_Purchases_kg) × 100. Ejemplo: Waste últimos 30 días: 50 kg, Purchases últimos 30 días: 1,200 kg, Waste_Rate = (50/1200) × 100 = 4.17%. Benchmark industria: <5% excelente, 5-10% aceptable, >10% problemático",
|
||||||
|
"dashboard": "Dashboard → Waste Tracker → Waste Rate: 4.2% (último mes, -1.5% vs mes anterior)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"metric": "Waste Cost (Coste de Desperdicio)",
|
||||||
|
"calculation": "Waste_Cost = Σ(waste_quantity × unit_cost) para todos WASTE movements. Incluye breakdown por waste_reason: expired: 120€ (60%), damaged: 50€ (25%), overproduction: 30€ (15%)",
|
||||||
|
"dashboard": "Dashboard → Waste Tracker → Total Waste Cost: 200€ (último mes)"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"complianceAndAudit": {
|
||||||
|
"title": "Cumplimiento HACCP y Trazabilidad",
|
||||||
|
"features": [
|
||||||
|
{
|
||||||
|
"feature": "Trazabilidad Forward & Backward",
|
||||||
|
"description": "Forward Tracing (de ingrediente a producto final): Pregunta: '¿Dónde fue usado lote INV-001 de harina?' Query: SELECT * FROM stock_movements WHERE stock_id = INV-001 AND movement_type = PRODUCTION_USE. Resultado: Usado en BATCH-20260113-005 (200 baguettes), BATCH-20260113-012 (150 pan rústico). Conclusión: Si lote INV-001 tiene contaminación, sabes exactamente qué productos finales afectó. Backward Tracing (de producto final a ingredientes): Pregunta: '¿Qué lotes de ingredientes se usaron en BATCH-005?' Query: SELECT * FROM stock_movements WHERE reference_number = BATCH-005 AND movement_type = PRODUCTION_USE. Resultado: Harina lote INV-001 (30 kg), Harina lote INV-002 (20 kg), Levadura lote INV-015 (0.8 kg), Sal lote INV-020 (2 kg). Conclusión: Si cliente reporta problema con BATCH-005, rastrear ingredientes exactos usados. Endpoint: GET /api/v1/tenants/{tenant_id}/inventory/stock/movements?stock_id={id} o ?reference_number={batch}. Tiempo de query: <100ms (indexes optimizados)."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"feature": "Auditoría Completa de Movimientos",
|
||||||
|
"description": "Cada StockMovement registra 15+ campos de auditoría: movement_type (PURCHASE, PRODUCTION_USE, WASTE, TRANSFER, ADJUSTMENT), stock_id (qué lote afectado), ingredient_id (qué ingrediente), tenant_id (multi-tenant isolation), user_id (quién ejecutó movimiento), quantity (cuánto movido), movement_date (timestamp preciso con timezone), reference_number (link a orden de compra, batch de producción, etc.), notes (comentarios libres), quantity_before (cantidad antes del movimiento), quantity_after (cantidad después), batch_number (trazabilidad de lote), expiration_date (fecha caducidad del lote afectado), cost_per_unit (valoración en momento del movimiento), total_cost (quantity × cost_per_unit). Export: GET /api/v1/tenants/{tenant_id}/inventory/stock/movements?export=true&format=excel → descarga Excel con TODOS los movimientos filtrados. Filters disponibles: date_range (ej: últimos 90 días para auditoría trimestral), ingredient_id, movement_type, user_id (movimientos de un operador específico). Uso: Auditorías internas/externas, Investigaciones de discrepancias, Análisis de consumo histórico, Compliance HACCP/ISO 22000."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"feature": "Food Safety Alerts & Compliance",
|
||||||
|
"description": "Sistema de alertas específicas de seguridad alimentaria: Temperature Breach: Si temperature_log fuera de rango (storage_temperature_min/max) por >30 min → alerta 'food_safety.temperature_breach'. Data: sensor_id, location (ej: 'Cámara Fría 1'), temperature_actual, temperature_target, duration_minutes, affected_stocks (qué lotes estaban en esa ubicación). Acción: Inspeccionar productos afectados, potencial quarantine. Expired in Cold Chain: Productos refrigerados expirados → severity='critical' (mayor riesgo que secos). Cross-Contamination Risk: Si lote marcado 'allergen:gluten' se mueve a location usada para 'allergen:none' → alerta. Quarantine Management: Lotes con quality_status='quarantined' bloqueados para producción hasta: Manager ejecuta quality inspection, Aprueba (cambia a 'good') o Rechaza (marca como WASTE). Compliance Reports: GET /api/v1/tenants/{tenant_id}/inventory/food-safety/compliance?period=monthly. Genera reporte con: Temperature breach incidents, Expired items timeline, Quarantine actions log, HACCP checklist compliance %. Exportable para auditorías sanitarias."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"feature": "Batch Recall Simulation",
|
||||||
|
"description": "Próximamente: herramienta de simulación de retirada de lote. Input: batch_number (ej: INV-001 contaminado). Sistema calcula automáticamente: Qué productos finales usaron ese lote (forward tracing), Cuántas unidades vendidas de esos productos (link con Sales), A qué clientes (si B2B con customer tracking), Sugerencias de recall notice, Coste estimado de la retirada. Output: Reporte detallado para gestión de crisis. Objetivo: Respuesta <1 hora en caso de alerta sanitaria (vs días manualmente)."
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"uiComponents": {
|
||||||
|
"title": "Interfaz de Usuario (Frontend)",
|
||||||
|
"components": [
|
||||||
|
{
|
||||||
|
"component": "InventoryPage.tsx",
|
||||||
|
"path": "/dashboard/inventory",
|
||||||
|
"description": "Página principal de inventario. Vista de tabla: Columnas: Nombre, Categoría, Stock Actual, Unidad, Status (Low Stock, Expiring, OK, Overstock, Expired), Acciones. Filtros: Por categoría (FLOURS, DAIRY, BREAD, PASTRIES, etc.), Por status (low_stock, expiring, expired, all), Búsqueda por nombre (real-time). Status badges: Color-coded (rojo=crítico, naranja=warning, verde=OK), Iconos (⚠️ low stock, ⏰ expiring, ❌ expired, 📦 overstock), Click en badge → detalle modal. Acciones rápidas: + Add Stock (abre AddStockModal), View Batches (abre BatchModal con lotes del ingrediente), View History (abre StockHistoryModal con movimientos), Edit Ingredient (editar nombre, categoría, umbrales). Performance: Paginación 50 items/página, Lazy loading, Cache 2 min (staleTime: 120s). Endpoint: GET /api/v1/tenants/{tenant_id}/inventory/ingredients con stock aggregation."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"component": "AddStockModal.tsx",
|
||||||
|
"description": "Modal para añadir entrada de stock (compra, recepción). Form sections: Ingredient Selection: Dropdown con todos los ingredientes, Autocomplete con búsqueda, Muestra average_cost actual del ingrediente. Quantity & Cost: current_quantity (required, number), unit_cost (opcional, default a average_cost del ingrediente), total_cost (auto-calculado = qty × cost, read-only). Batch Information: batch_number (auto-generado formato INV-YYYYMMDD-NNN, editable), lot_number (número lote proveedor, opcional), supplier_id (dropdown de proveedores, opcional). Expiration & Storage: expiration_date (date picker, requerido si ingrediente es perecedero), best_before_date (opcional), production_stage (raw_ingredient default), quality_status (good default, opciones: good, damaged, quarantined), storage_location (text, ej: 'Almacén A, Estante 2'), requires_refrigeration (checkbox, auto-checked si ingrediente requiere), storage_temperature_min/max (si refrigerado, ej: 2-8°C). Notes: Textarea libre para observaciones. Validations: Quantity > 0, Unit cost >= 0, Expiration date > today (warning si no, permite override), Storage temp dentro de rango aceptable para ingrediente. Submit → POST /api/v1/tenants/{tenant_id}/inventory/stock → Success: Modal cierra, Lista se refresca, Toast notification 'Stock añadido: 150 kg Harina T-55'."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"component": "BatchModal.tsx",
|
||||||
|
"description": "Modal para gestionar lotes de un ingrediente. Layout: Header: Ingrediente: Harina T-55, Stock Total: 285 kg (suma de lotes), Botón 'Add New Batch'. Batch Cards: Array de cards, uno por lote, Collapsible (collapsed por default para UX limpia), Expand al click. Batch Card Content: Batch Number: INV-20260113-001 (bold), Quantity: 120 kg / 150 kg original (current / initial), Expiration: 2026-02-15 (32 días restantes) → color-coded, Supplier: Harinera La Espiga, Cost: 0.85€/kg (102€ total), Quality: good ✓ (badge verde), Location: Almacén A, Estante 2, Storage: Refrigeration not required (icon + text). Actions por batch: Edit: Abre EditBatchModal (cambiar quantity, expiration, location, quality), Mark as Waste: Abre WasteModal (ingresar waste_quantity, waste_reason), Delete: Admin only, confirmación modal, soft delete. Color coding expiration: Rojo: Expired o expira hoy/mañana, Naranja: 2-7 días, Amarillo: 8-30 días, Gris: >30 días. Sorting: Por expiration_date ASC (más urgente primero, FIFO order). Endpoint: GET /api/v1/tenants/{tenant_id}/inventory/stock?ingredient_id={id}."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"component": "StockHistoryModal.tsx",
|
||||||
|
"description": "Modal de historial de movimientos. Filters: Date range picker (default: últimos 30 días), Movement type multi-select (PURCHASE, PRODUCTION_USE, WASTE, TRANSFER, ADJUSTMENT, ALL), User filter (dropdown de usuarios, opcional). Table Columns: Date (timestamp), Type (badge color-coded: PURCHASE=green, PRODUCTION_USE=blue, WASTE=red, TRANSFER=orange, ADJUSTMENT=yellow), Quantity (con + o - prefix según IN/OUT), Batch Number, Reference (link a PO, production batch, etc.), User (quién ejecutó), Notes. Pagination: 20 movimientos/página. Export: Botón 'Export to Excel' → descarga historial filtrado. Drill-down: Click en Reference Number (ej: BATCH-005) → navega a batch detail page, Click en Batch Number (ej: INV-001) → abre BatchModal con ese lote específico. Endpoint: GET /api/v1/tenants/{tenant_id}/inventory/stock/movements?ingredient_id={id}&start_date=X&end_date=Y&movement_type=Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"component": "WasteTrackerWidget.tsx",
|
||||||
|
"path": "/dashboard (widget)",
|
||||||
|
"description": "Widget de dashboard para monitorear desperdicio. Métricas principales: Total Waste (último mes): 50 kg, 200€, Waste Rate: 4.2% (verde si <5%, amarillo 5-10%, rojo >10%), Trend: ↓ -1.5% vs mes anterior (verde). Breakdown por waste_reason: Chart de dona (pie chart) con: Expired: 60% (30 kg), Damaged: 25% (12.5 kg), Overproduction: 10% (5 kg), Other: 5% (2.5 kg). Top 3 wasted ingredients: Harina T-55: 15 kg (30€), Mantequilla: 8 kg (80€, más costoso aunque menos kg), Leche: 12 L (18€). Actions: Click en ingredient → abre detail modal con waste history, Link 'See Full Report' → navega a /analytics/waste. Actualización: Polling cada 5 min (o WebSocket si disponible). Endpoint: GET /api/v1/tenants/{tenant_id}/inventory/analytics/waste?period=30d"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"integrationPoints": {
|
||||||
|
"title": "Integraciones con Otros Módulos",
|
||||||
|
"integrations": [
|
||||||
|
{
|
||||||
|
"module": "Production Service",
|
||||||
|
"description": "Bidireccional: Production → Inventory (consumo automático): Cuando ProductionBatch status = COMPLETED, Production Service llama InventoryClient.consume_stock(ingredient_ids, quantities, reference=batch_id, fifo=true), Inventory descuenta stock usando FIFO, devuelve consumed_items detail, Production registra qué lotes se usaron en batch metadata. Inventory → Production (alertas de disponibilidad): Antes de crear batch, Production consulta InventoryClient.check_availability(ingredient_requirements), Si algún ingrediente insuficiente → batch status = ON_HOLD, alerta al manager. Validation en tiempo real: UI de creación de batch muestra stock actual de ingredientes necesarios con color-coding (verde=OK, rojo=insuficiente)."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"module": "Sales Service",
|
||||||
|
"description": "Sales → Inventory (producto final sale): Cuando venta registrada, Sales puede decrementar stock de producto final (opcional, depende de configuración), Si enabled: SalesClient llama InventoryClient.consume_stock(product_id, quantity_sold, reference=sale_id), Útil para tracking de productos finales en inventario (no solo ingredientes). Inventory → Sales (datos para predicciones): Forecasting Service usa inventory levels para ajustar predicciones: Si stock alto de producto X → puede bajar producción sugerida, Si stock bajo → aumentar urgencia de producción."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"module": "Procurement Service (Compras)",
|
||||||
|
"description": "Inventory → Procurement (auto-generación de órdenes): Sistema detecta ingredient con stock < reorder_point, Genera PurchaseOrderSuggestion automática con: ingredient_id, suggested_quantity (= reorder_quantity configurado), supplier_id (proveedor principal del ingrediente), estimated_cost (= suggested_qty × last_purchase_price), urgency (= hours_until_stockout), Manager revisa sugerencias en Dashboard → Procurement → Suggested Orders, Click 'Create PO' → auto-llena formulario de orden de compra. Procurement → Inventory (recepción de mercancía): Cuando PurchaseOrder delivered, Procurement notifica Inventory, UI muestra 'Pending Receipt' para esa PO, Click → auto-llena AddStockModal con datos de la PO (ingredient, quantity, supplier, cost), Confirmar → crea stock entry linked a PO."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"module": "Analytics Service",
|
||||||
|
"description": "Inventory → Analytics (data streaming): Todos los movimientos de stock se publican como eventos a Analytics Service, Analytics agrega datos para: Consumo histórico por ingrediente (tendencias), Waste analytics (causas, costes, trends), Inventory turnover por categoría, Stock value fluctuations. Analytics → Inventory (recomendaciones): Analytics detecta patrones: 'Harina T-55: consumo aumentó 25% últimos 30 días → sugerir aumentar reorder_point de 50 kg a 65 kg', 'Mantequilla: waste por caducidad 15% último mes → sugerir reducir reorder_quantity o negociar compras más frecuentes con proveedor', Dashboard → Inventory → Optimization Recommendations muestra sugerencias AI-powered."
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"bestPractices": [
|
||||||
{
|
{
|
||||||
"title": "Recepción de Pedidos",
|
"practice": "Configurar Umbrales Realistas",
|
||||||
"description": "Cuando llega un pedido del proveedor: 1) Registra la entrega en el sistema, 2) Escanea código de barras o introduce cantidad manualmente, 3) Registra lote y fecha de caducidad, 4) Stock se actualiza automáticamente"
|
"description": "Fórmula reorder_point: reorder_point = (daily_usage × supplier_lead_time_days) × safety_factor. Ejemplo: Harina T-55: daily_usage = 50 kg/día (promedio últimos 30 días), lead_time = 2 días (tu proveedor entrega en 2 días), safety_factor = 1.2 (20% buffer para variabilidad), reorder_point = 50 × 2 × 1.2 = 120 kg. Interpretación: Cuando stock baje de 120 kg, hacer pedido. Con consumo de 50 kg/día, tienes 2.4 días de stock (tiempo suficiente para recibir pedido en 2 días). Ajustar safety_factor: 1.1-1.2 (10-20%) para ingredientes estables y proveedores fiables, 1.3-1.5 (30-50%) para ingredientes volátiles o proveedores con lead time variable, 1.5-2.0 (50-100%) para ingredientes críticos sin sustituto."
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"title": "Actualización Automática",
|
"practice": "Inventario Físico Regular",
|
||||||
"description": "Al completar un lote de producción, el sistema descuenta automáticamente los ingredientes usando FIFO. Si produces 100 baguettes (receta: 500g harina cada), descuenta 50kg de harina del lote más antiguo"
|
"description": "Frecuencia recomendada: Inventario completo: Trimestral (cada 3 meses) para todos los ingredientes, Inventario parcial (ABC analysis): Categoría A (alto valor, 20% ingredientes, 80% valor): Mensual, Categoría B (valor medio, 30% ingredientes, 15% valor): Bimestral, Categoría C (bajo valor, 50% ingredientes, 5% valor): Trimestral. Proceso: 1) Generar reporte de stock teórico: GET /inventory/stock/summary, 2) Contar físicamente (mejor fin de mes cuando stock es bajo), 3) Comparar stock_teorico vs stock_fisico, 4) Si discrepancia > 5%: Investigar (pérdida, theft, error de registro), Ajustar: PUT /stock/{id} con adjustment_reason='physical_inventory', 5) Analizar tendencias de discrepancias (si sistemáticas → problema de proceso). Beneficios: Detecta mermas ocultas (evaporación, derrames, robo), Corrige errores acumulados de registro, Mantiene confianza en datos del sistema, Cumple normativa contable (inventarios auditables)."
|
||||||
}
|
|
||||||
],
|
|
||||||
"compliance": [
|
|
||||||
{
|
|
||||||
"name": "HACCP",
|
|
||||||
"description": "Sistema cumple con normas HACCP de seguridad alimentaria. Trazabilidad completa de lotes, temperaturas de almacenaje"
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Auditoría",
|
"practice": "Rotación FIFO Disciplinada",
|
||||||
"description": "Historial completo de movimientos de stock para auditorías. Exportable a PDF/Excel"
|
"description": "Aunque sistema consume FIFO automáticamente en producción, el almacenaje físico debe facilitar FIFO: Organización de almacén: Lotes nuevos atrás, antiguos adelante (operadores toman de adelante naturalmente), Etiquetado claro: Fecha recepción + Fecha caducidad visible en todos los lotes, Zonas separadas por urgencia: Zona A (expira 0-7 días): Usar PRIMERO, prominente, Zona B (expira 8-30 días): Usar segundo, Zona C (>30 días): Stock de reserva. Auditoría física semanal: Revisar que lotes adelante son realmente los más antiguos (comparar etiquetas vs ubicación), Si encuentras lote antiguo atrás → mover a adelante. Capacitación personal: Entrenar equipo en FIFO importance, Consecuencias de no seguir FIFO (caducidades, waste, coste). Métricas de compliance: Waste_by_expiration: Si >60% del waste es por caducidad → posible problema de FIFO físico (vs otros waste reasons como damaged, overproduction)."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"practice": "Monitoreo de Caducidades Proactivo",
|
||||||
|
"description": "No esperar alertas del sistema, revisar proactivamente: Daily check (5 min cada mañana): Dashboard → Expiring Stock Widget → Items expiring next 3 days, Planificar uso inmediato (incluir en producción del día o día siguiente). Weekly review (15 min cada lunes): GET /stock/expiring?days_ahead=14 → items expiring próximas 2 semanas, Priorizar en plan de producción semanal, Considerar promociones (vender productos con ingredientes próximos a caducar a precio reducido para mover stock rápido). Monthly analysis (30 min fin de mes): Waste report → % waste por expired, Identificar ingredientes con alta tasa de caducidad (compras excesivas? shelf life corto?), Ajustar reorder_quantity o negociar compras más frecuentes/pequeñas con proveedor. Automation: Configurar notificaciones automáticas: Email diario con items expiring <3 days (criticales), Email semanal con items expiring <7 days (planificar)."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"practice": "Optimización de Niveles de Stock",
|
||||||
|
"description": "Revisar y ajustar trimestralmente: Analizar consumo real: GET /inventory/analytics?metric=consumption&period=90d → consumption_daily_avg por ingrediente, Comparar con reorder_point actual: Si consumo aumentó: Subir reorder_point (evitar roturas de stock), Si consumo bajó: Bajar reorder_point (reducir capital inmovilizado). Optimizar reorder_quantity: Economic Order Quantity (EOQ) formula: EOQ = √[(2 × annual_demand × order_cost) / holding_cost_per_unit], Ejemplo: Harina demand anual: 18,000 kg, Order cost (logística, admin): 25€/pedido, Holding cost (almacenaje, capital): 0.50€/kg/año, EOQ = √[(2×18000×25) / 0.50] = √1,800,000 = 1,342 kg por pedido. Balancear vs supplier constraints: Si EOQ sugiere 1,342 kg pero proveedor vende en sacos de 25 kg → redondear a 1,350 kg (54 sacos), Si proveedor ofrece descuento por volumen >2,000 kg → evaluar trade-off (ahorro compra vs coste almacenaje extra). Resultado: Minimize total cost = purchase cost + order cost + holding cost."
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"metrics": [
|
"troubleshooting": [
|
||||||
"Valor total de inventario (€)",
|
{
|
||||||
"Rotación de inventario (cuántas veces al año renuevas stock)",
|
"problem": "Discrepancias persistentes entre stock teórico y físico",
|
||||||
"Desperdicio por caducidad (kg y € al mes)",
|
"solutions": [
|
||||||
"Ingredientes con stock crítico"
|
"Auditoría de movimientos: GET /stock/movements?ingredient_id=X&date_range=last_30d → revisar TODOS los movimientos, buscar: Movimientos sin reference_number (posibles errores manuales), Ajustes frecuentes del mismo user_id (posible error sistemático o fraude), Consumos que no matchean con production batches completados",
|
||||||
]
|
"Verificar proceso de registro: ¿Personal registra TODAS las salidas (production, waste, transfers)?, ¿Hay consumos ad-hoc no registrados (chef toma ingredientes para pruebas)?, Implementar: Control de acceso físico a almacén (solo personal autorizado), Registro obligatorio de salidas (formulario, incluso para pequeñas cantidades)",
|
||||||
|
"Investigar pérdidas naturales: Ingredientes con evaporación (líquidos en contenedores no herméticos), Merma por manipulación (bolsas rotas, derrames), Condensación/humedad (cambios de peso en harinas), Solución: Registrar allowance de merma normal (ej: 1-2% en harinas), Comparar discrepancia vs allowance",
|
||||||
|
"Automatizar cuando sea posible: Si discrepancias en producción → integrar balanzas digitales con sistema (auto-registro de consumos), Si discrepancias en recepción → escaneo de códigos de barras al recibir (evita errores de entrada manual)"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"problem": "Alertas de stock bajo muy frecuentes (rotura de stock continua)",
|
||||||
|
"solutions": [
|
||||||
|
"Recalcular reorder_point: Fórmula actual: reorder_point = daily_usage × lead_time × 1.2, Revisar daily_usage: GET /analytics?metric=consumption&period=30d → usar promedio más reciente (últimos 30 días, no 90+ días si consumo cambió), Revisar lead_time: ¿Proveedor entrega realmente en 2 días o suele tardar 3-4? Usar lead_time_real máximo observado, Aumentar safety_factor de 1.2 a 1.3-1.5 si: Proveedor poco fiable (retrasos frecuentes), Consumo muy variable (CV >0.5), Ingrediente crítico sin sustituto",
|
||||||
|
"Optimizar reorder_quantity: Si haces pedidos pequeños → aumentar reorder_quantity para reducir frecuencia de pedidos, Negociar entregas más frecuentes con proveedor (semanal vs quincenal) → permite reducir reorder_point (menos stock de seguridad necesario)",
|
||||||
|
"Automatizar pedidos: Configurar auto-purchase orders para ingredientes críticos estables, Cuando stock < reorder_point → sistema crea PO automáticamente, Proveedor recibe email con pedido, Confirma delivery date, Manager solo aprueba (no crea manualmente)",
|
||||||
|
"Alertas tempranas: Configurar alert cuando stock = 1.5 × reorder_point (50% antes del punto crítico) → da más margen para actuar"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"problem": "Waste alto por caducidades (>10% del total)",
|
||||||
|
"solutions": [
|
||||||
|
"Analizar causas raíz: GET /analytics/waste?waste_reason=expired&period=90d, Breakdown por ingrediente: ¿Pocos ingredientes causan mayoría del waste? (Pareto 80/20), Top offenders → investigar específicamente cada uno",
|
||||||
|
"Soluciones por causa: Compras excesivas: Reducir reorder_quantity (comprar más frecuente, cantidades menores), Shelf life corto: Negociar con proveedor entregas más frecuentes (semanal vs quincenal), Productos de lento movimiento reducen frescura de rápidos, Consumo irregular (picos y valles): Usar forecasting para ajustar compras a demanda real, Evitar compras 'por si acaso' sin forecast que lo justifique, FIFO físico no respetado: Auditar almacén físico (lotes antiguos escondidos atrás?), Re-entrenar personal en FIFO importance",
|
||||||
|
"Tácticas de reducción: Promociones pre-expiration: Productos que caducan en 3-5 días → vender con descuento (mejor margen reducido que waste 100%), Donar antes de caducar: Contactar bancos de alimentos (aceptan productos 1-2 días antes de expiration), Tax benefits + RSC + zero waste, Transformar productos: Ingredientes próximos a caducar → usar en productos con shelf life corto (pan del día, producción para consumo inmediato)",
|
||||||
|
"Monitoreo continuo: Weekly waste review meeting (15 min), Meta: Reducir waste_rate 1% cada mes hasta alcanzar <5% objetivo"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"advancedFeatures": [
|
||||||
|
{
|
||||||
|
"feature": "AI-Powered Inventory Optimization (Roadmap)",
|
||||||
|
"description": "Machine Learning analizará patrones históricos para: Predecir consumo futuro por ingrediente (más preciso que promedios simples), Recomendar ajustes dinámicos de reorder_point según estacionalidad (verano vs invierno, festivos), Detectar anomalías (consumo inusual → posible theft, error de registro, nuevo patrón de demanda), Sugerir consolidación de proveedores (ingredientes similares de proveedores diferentes → negociar mejor precio con uno solo)."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"feature": "IoT Sensors Integration",
|
||||||
|
"description": "Integración con sensores de almacén: Temperature & Humidity Sensors: Monitoreo 24/7 de cámaras frías, almacenes, Alertas automáticas si temperatura fuera de rango (food safety), Weight Sensors en estanterías: Tracking automático de stock level sin registro manual, Detección de discrepancias en tiempo real (stock_sensor vs stock_sistema), RFID Tags en lotes: Escaneo automático al mover lotes (recepción, uso, transferencia), Trazabilidad automática sin intervención humana. Status: En roadmap, pilotos con clientes enterprise."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"feature": "Multi-Location Inventory (Enterprise)",
|
||||||
|
"description": "Para empresas con obrador central + sucursales: Stock unificado a nivel tenant con location dimension, Transfers automáticos: Obrador produce → auto-transfer a sucursales según forecast por sucursal, Dashboard consolidado: Stock total cross-location, Stock por location (drill-down), Transfer optimization: Sistema sugiere qué sucursal necesita reposición urgente, cuánto transferir desde obrador/otras sucursales. Disponible en plan Enterprise."
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"conclusion": "El sistema de inventario es la columna vertebral operativa de BakeWise. Invierte tiempo en: 1) Configuración inicial correcta (umbrales realistas de reorder_point basados en consumo real + lead time), 2) Disciplina de registro (TODAS las entradas y salidas, sin excepciones), 3) Inventario físico regular (trimestral mínimo, mensual ideal para ingredientes críticos), 4) Análisis continuo (revisar waste, discrepancias, niveles de stock semanalmente). Con inventario bien gestionado, lograrás: Waste <5% (vs 15-25% sin sistema), Roturas de stock <2% (vs 10-20% sin sistema), Capital inmovilizado -30% (stock óptimo, no excesivo ni insuficiente), Compliance HACCP 100% (trazabilidad completa para auditorías). El ROI típico del módulo de inventario es 300-500% en el primer año solo por reducción de waste."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"posIntegration": {
|
"posIntegration": {
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
92
frontend/src/pages/public/RegisterCompletePage.tsx
Normal file
92
frontend/src/pages/public/RegisterCompletePage.tsx
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
import React, { useEffect } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||||
|
import { Card, Button } from '../../components/ui';
|
||||||
|
import { CheckCircle, AlertCircle } from 'lucide-react';
|
||||||
|
import { showToast } from '../../utils/toast';
|
||||||
|
|
||||||
|
export const RegisterCompletePage: React.FC = () => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [searchParams] = useSearchParams();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Check for Stripe redirect parameters
|
||||||
|
const setupIntent = searchParams.get('setup_intent');
|
||||||
|
const redirectStatus = searchParams.get('redirect_status');
|
||||||
|
const setupIntentClientSecret = searchParams.get('setup_intent_client_secret');
|
||||||
|
|
||||||
|
if (redirectStatus === 'succeeded' && setupIntent) {
|
||||||
|
// Successful 3DS authentication
|
||||||
|
showToast.success(t('auth:register.3ds_success', 'Autenticación 3D Secure completada con éxito'), {
|
||||||
|
title: t('auth:alerts.success', 'Éxito')
|
||||||
|
});
|
||||||
|
|
||||||
|
// Redirect to login page after successful authentication
|
||||||
|
setTimeout(() => {
|
||||||
|
navigate('/login', {
|
||||||
|
state: {
|
||||||
|
from3DS: true,
|
||||||
|
message: t('auth:register.3ds_complete', 'Tu tarjeta ha sido verificada. Por favor, inicia sesión.')
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, 3000);
|
||||||
|
} else if (redirectStatus === 'failed') {
|
||||||
|
// Failed 3DS authentication
|
||||||
|
showToast.error(t('auth:register.3ds_failed', 'La autenticación 3D Secure ha fallado'), {
|
||||||
|
title: t('auth:alerts.authentication_error', 'Error de autenticación')
|
||||||
|
});
|
||||||
|
|
||||||
|
// Redirect to registration page to try again
|
||||||
|
setTimeout(() => {
|
||||||
|
navigate('/register', {
|
||||||
|
state: {
|
||||||
|
error: t('auth:register.3ds_failed_try_again', 'La autenticación 3D Secure ha fallado. Por favor, intenta de nuevo con otra tarjeta.')
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, 3000);
|
||||||
|
}
|
||||||
|
}, [searchParams, navigate, t]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center bg-bg-primary p-4">
|
||||||
|
<Card className="w-full max-w-md p-8 text-center">
|
||||||
|
<div className="mb-6">
|
||||||
|
{searchParams.get('redirect_status') === 'succeeded' ? (
|
||||||
|
<CheckCircle className="w-16 h-16 text-success mx-auto mb-4" />
|
||||||
|
) : (
|
||||||
|
<AlertCircle className="w-16 h-16 text-danger mx-auto mb-4" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2 className="text-2xl font-bold text-text-primary mb-4">
|
||||||
|
{searchParams.get('redirect_status') === 'succeeded'
|
||||||
|
? t('auth:register.processing_3ds', 'Procesando autenticación')
|
||||||
|
: t('auth:register.3ds_error', 'Error de autenticación')}
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<p className="text-text-secondary mb-6">
|
||||||
|
{searchParams.get('redirect_status') === 'succeeded'
|
||||||
|
? t('auth:register.3ds_redirect_message', 'Tu autenticación 3D Secure se ha completado. Serás redirigido en breve...')
|
||||||
|
: t('auth:register.3ds_failed_message', 'Hubo un problema con la autenticación 3D Secure. Serás redirigido para intentar de nuevo...')}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
onClick={() => navigate('/login')}
|
||||||
|
className="w-full mb-3"
|
||||||
|
>
|
||||||
|
{t('auth:register.go_to_login', 'Ir a inicio de sesión')}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => navigate('/register')}
|
||||||
|
className="w-full"
|
||||||
|
>
|
||||||
|
{t('auth:register.try_again', 'Intentar registro de nuevo')}
|
||||||
|
</Button>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -188,6 +188,39 @@ export const useAuthStore = create<AuthState>()(
|
|||||||
|
|
||||||
const response = await authService.registerWithSubscription(userData);
|
const response = await authService.registerWithSubscription(userData);
|
||||||
|
|
||||||
|
// NEW ARCHITECTURE: Check if SetupIntent verification is required
|
||||||
|
if (response && response.requires_action) {
|
||||||
|
// SetupIntent required - NO user created yet, NO tokens returned
|
||||||
|
// Store registration data for post-3DS completion
|
||||||
|
const pendingRegistrationData = {
|
||||||
|
email: userData.email,
|
||||||
|
password: userData.password,
|
||||||
|
full_name: userData.full_name,
|
||||||
|
setup_intent_id: response.setup_intent_id,
|
||||||
|
plan_id: response.plan_id,
|
||||||
|
payment_method_id: response.payment_method_id,
|
||||||
|
billing_interval: response.billing_interval,
|
||||||
|
coupon_code: response.coupon_code,
|
||||||
|
customer_id: response.customer_id,
|
||||||
|
payment_customer_id: response.payment_customer_id,
|
||||||
|
trial_period_days: response.trial_period_days,
|
||||||
|
client_secret: response.client_secret
|
||||||
|
};
|
||||||
|
|
||||||
|
// Store in session storage for post-3DS completion
|
||||||
|
sessionStorage.setItem('pending_registration_data', JSON.stringify(pendingRegistrationData));
|
||||||
|
|
||||||
|
set({
|
||||||
|
isLoading: false,
|
||||||
|
error: null,
|
||||||
|
pendingRegistrationData,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Return the SetupIntent data for frontend to handle 3DS
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
// OLD FLOW: No SetupIntent required - user created and authenticated
|
||||||
if (response && response.access_token) {
|
if (response && response.access_token) {
|
||||||
// Set the auth tokens on the API client immediately
|
// Set the auth tokens on the API client immediately
|
||||||
apiClient.setAuthToken(response.access_token);
|
apiClient.setAuthToken(response.access_token);
|
||||||
@@ -223,6 +256,60 @@ export const useAuthStore = create<AuthState>()(
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
completeRegistrationAfterSetupIntent: async (completionData: {
|
||||||
|
email: string;
|
||||||
|
password: string;
|
||||||
|
full_name: string;
|
||||||
|
setup_intent_id: string;
|
||||||
|
plan_id: string;
|
||||||
|
payment_method_id: string;
|
||||||
|
billing_interval: 'monthly' | 'yearly';
|
||||||
|
coupon_code?: string;
|
||||||
|
}) => {
|
||||||
|
try {
|
||||||
|
set({ isLoading: true, error: null });
|
||||||
|
|
||||||
|
const response = await authService.completeRegistrationAfterSetupIntent(completionData);
|
||||||
|
|
||||||
|
if (response && response.access_token) {
|
||||||
|
// Set the auth tokens on the API client immediately
|
||||||
|
apiClient.setAuthToken(response.access_token);
|
||||||
|
if (response.refresh_token) {
|
||||||
|
apiClient.setRefreshToken(response.refresh_token);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store subscription ID in state for onboarding flow
|
||||||
|
const pendingSubscriptionId = response.subscription_id || null;
|
||||||
|
|
||||||
|
// Clear pending registration data from session storage
|
||||||
|
sessionStorage.removeItem('pending_registration_data');
|
||||||
|
|
||||||
|
set({
|
||||||
|
user: response.user || null,
|
||||||
|
token: response.access_token,
|
||||||
|
refreshToken: response.refresh_token || null,
|
||||||
|
isAuthenticated: true,
|
||||||
|
isLoading: false,
|
||||||
|
error: null,
|
||||||
|
pendingSubscriptionId,
|
||||||
|
pendingRegistrationData: null,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
throw new Error('Registration completion failed');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
set({
|
||||||
|
user: null,
|
||||||
|
token: null,
|
||||||
|
refreshToken: null,
|
||||||
|
isAuthenticated: false,
|
||||||
|
isLoading: false,
|
||||||
|
error: error instanceof Error ? error.message : 'Error completando el registro',
|
||||||
|
});
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
logout: () => {
|
logout: () => {
|
||||||
// Clear the auth tokens from API client
|
// Clear the auth tokens from API client
|
||||||
apiClient.setAuthToken(null);
|
apiClient.setAuthToken(null);
|
||||||
@@ -446,6 +533,7 @@ export const useAuthActions = () => useAuthStore((state) => ({
|
|||||||
login: state.login,
|
login: state.login,
|
||||||
register: state.register,
|
register: state.register,
|
||||||
registerWithSubscription: state.registerWithSubscription,
|
registerWithSubscription: state.registerWithSubscription,
|
||||||
|
completeRegistrationAfterSetupIntent: state.completeRegistrationAfterSetupIntent,
|
||||||
logout: state.logout,
|
logout: state.logout,
|
||||||
refreshAuth: state.refreshAuth,
|
refreshAuth: state.refreshAuth,
|
||||||
updateUser: state.updateUser,
|
updateUser: state.updateUser,
|
||||||
|
|||||||
@@ -48,6 +48,12 @@ async def proxy_subscription_status(request: Request, tenant_id: str = Path(...)
|
|||||||
target_path = f"/api/v1/subscriptions/{tenant_id}/status"
|
target_path = f"/api/v1/subscriptions/{tenant_id}/status"
|
||||||
return await _proxy_to_tenant_service(request, target_path)
|
return await _proxy_to_tenant_service(request, target_path)
|
||||||
|
|
||||||
|
@router.api_route("/subscriptions/{tenant_id}/payment-method", methods=["GET", "OPTIONS"])
|
||||||
|
async def proxy_payment_method(request: Request, tenant_id: str = Path(...)):
|
||||||
|
"""Proxy payment method request to tenant service"""
|
||||||
|
target_path = f"/api/v1/subscriptions/{tenant_id}/payment-method"
|
||||||
|
return await _proxy_to_tenant_service(request, target_path)
|
||||||
|
|
||||||
@router.api_route("/subscriptions/cancel", methods=["POST", "OPTIONS"])
|
@router.api_route("/subscriptions/cancel", methods=["POST", "OPTIONS"])
|
||||||
async def proxy_subscription_cancel(request: Request):
|
async def proxy_subscription_cancel(request: Request):
|
||||||
"""Proxy subscription cancellation request to tenant service"""
|
"""Proxy subscription cancellation request to tenant service"""
|
||||||
@@ -66,6 +72,12 @@ async def proxy_payment_customer_create(request: Request):
|
|||||||
target_path = "/api/v1/payment-customers/create"
|
target_path = "/api/v1/payment-customers/create"
|
||||||
return await _proxy_to_tenant_service(request, target_path)
|
return await _proxy_to_tenant_service(request, target_path)
|
||||||
|
|
||||||
|
@router.api_route("/setup-intents/{setup_intent_id}/verify", methods=["GET", "OPTIONS"])
|
||||||
|
async def proxy_setup_intent_verify(request: Request, setup_intent_id: str):
|
||||||
|
"""Proxy SetupIntent verification request to tenant service"""
|
||||||
|
target_path = f"/api/v1/setup-intents/{setup_intent_id}/verify"
|
||||||
|
return await _proxy_to_tenant_service(request, target_path)
|
||||||
|
|
||||||
@router.api_route("/subscriptions/reactivate", methods=["POST", "OPTIONS"])
|
@router.api_route("/subscriptions/reactivate", methods=["POST", "OPTIONS"])
|
||||||
async def proxy_subscription_reactivate(request: Request):
|
async def proxy_subscription_reactivate(request: Request):
|
||||||
"""Proxy subscription reactivation request to tenant service"""
|
"""Proxy subscription reactivation request to tenant service"""
|
||||||
@@ -116,10 +128,13 @@ async def _proxy_request(request: Request, target_path: str, service_url: str):
|
|||||||
|
|
||||||
# Debug logging
|
# Debug logging
|
||||||
user_context = getattr(request.state, 'user', None)
|
user_context = getattr(request.state, 'user', None)
|
||||||
|
service_context = getattr(request.state, 'service', None)
|
||||||
if user_context:
|
if user_context:
|
||||||
logger.info(f"Forwarding subscription request to {url} with user context: user_id={user_context.get('user_id')}, email={user_context.get('email')}, subscription_tier={user_context.get('subscription_tier', 'not_set')}")
|
logger.info(f"Forwarding subscription request to {url} with user context: user_id={user_context.get('user_id')}, email={user_context.get('email')}, subscription_tier={user_context.get('subscription_tier', 'not_set')}")
|
||||||
|
elif service_context:
|
||||||
|
logger.debug(f"Forwarding subscription request to {url} with service context: service_name={service_context.get('service_name')}, user_type=service")
|
||||||
else:
|
else:
|
||||||
logger.warning(f"No user context available when forwarding subscription request to {url}")
|
logger.warning(f"No user or service context available when forwarding subscription request to {url}")
|
||||||
|
|
||||||
# Get request body if present
|
# Get request body if present
|
||||||
body = None
|
body = None
|
||||||
|
|||||||
@@ -13,6 +13,20 @@ from app.schemas.auth import (
|
|||||||
UserRegistration, UserLogin, TokenResponse, RefreshTokenRequest,
|
UserRegistration, UserLogin, TokenResponse, RefreshTokenRequest,
|
||||||
PasswordChange, PasswordReset, UserResponse
|
PasswordChange, PasswordReset, UserResponse
|
||||||
)
|
)
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
|
||||||
|
# Schema for SetupIntent completion data
|
||||||
|
class SetupIntentCompletionData(BaseModel):
|
||||||
|
email: str
|
||||||
|
password: str
|
||||||
|
full_name: str
|
||||||
|
setup_intent_id: str
|
||||||
|
plan_id: str
|
||||||
|
payment_method_id: str
|
||||||
|
billing_interval: str = "monthly"
|
||||||
|
coupon_code: Optional[str] = None
|
||||||
from app.services.auth_service import EnhancedAuthService
|
from app.services.auth_service import EnhancedAuthService
|
||||||
from app.models.users import User
|
from app.models.users import User
|
||||||
from app.core.database import get_db
|
from app.core.database import get_db
|
||||||
@@ -102,8 +116,7 @@ async def register(
|
|||||||
detail="Registration failed"
|
detail="Registration failed"
|
||||||
)
|
)
|
||||||
|
|
||||||
@router.post("/api/v1/auth/register-with-subscription", response_model=TokenResponse)
|
@router.post("/api/v1/auth/register-with-subscription")
|
||||||
@track_execution_time("enhanced_registration_with_subscription_duration_seconds", "auth-service")
|
|
||||||
async def register_with_subscription(
|
async def register_with_subscription(
|
||||||
user_data: UserRegistration,
|
user_data: UserRegistration,
|
||||||
request: Request,
|
request: Request,
|
||||||
@@ -112,18 +125,20 @@ async def register_with_subscription(
|
|||||||
"""
|
"""
|
||||||
Register new user and create subscription in one call
|
Register new user and create subscription in one call
|
||||||
|
|
||||||
This endpoint implements the new registration flow where:
|
NEW ARCHITECTURE: User is ONLY created AFTER payment verification
|
||||||
1. User is created
|
|
||||||
2. Payment customer is created via tenant service
|
Flow:
|
||||||
3. Tenant-independent subscription is created via tenant service
|
1. Validate user data
|
||||||
4. Subscription data is stored in onboarding progress
|
2. Create payment customer via tenant service
|
||||||
5. User is authenticated and returned with tokens
|
3. Create SetupIntent via tenant service
|
||||||
|
4. If SetupIntent requires_action: Return SetupIntent data WITHOUT creating user
|
||||||
|
5. If no SetupIntent required: Create user, create subscription, return tokens
|
||||||
|
|
||||||
The subscription will be linked to a tenant during the onboarding flow.
|
The subscription will be linked to a tenant during the onboarding flow.
|
||||||
"""
|
"""
|
||||||
metrics = get_metrics_collector(request)
|
metrics = get_metrics_collector(request)
|
||||||
|
|
||||||
logger.info("Registration with subscription attempt using new architecture",
|
logger.info("Registration with subscription attempt using secure architecture",
|
||||||
email=user_data.email)
|
email=user_data.email)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -146,54 +161,135 @@ async def register_with_subscription(
|
|||||||
detail="Full name is required"
|
detail="Full name is required"
|
||||||
)
|
)
|
||||||
|
|
||||||
# Step 1: Register user using enhanced service
|
# NEW ARCHITECTURE: Create payment customer and SetupIntent BEFORE user creation
|
||||||
logger.info("Step 1: Creating user", email=user_data.email)
|
if user_data.subscription_plan and user_data.payment_method_id:
|
||||||
|
logger.info("Step 1: Creating payment customer and SetupIntent BEFORE user creation",
|
||||||
|
email=user_data.email,
|
||||||
|
plan=user_data.subscription_plan)
|
||||||
|
|
||||||
|
# Use tenant service orchestration endpoint for payment setup
|
||||||
|
# This creates payment customer and SetupIntent in one coordinated workflow
|
||||||
|
payment_setup_result = await auth_service.create_registration_payment_setup_via_tenant_service(
|
||||||
|
user_data=user_data
|
||||||
|
)
|
||||||
|
|
||||||
|
if not payment_setup_result or not payment_setup_result.get('success'):
|
||||||
|
logger.error("Payment setup failed",
|
||||||
|
email=user_data.email,
|
||||||
|
error="Payment setup returned no success")
|
||||||
|
|
||||||
|
if metrics:
|
||||||
|
metrics.increment_counter("enhanced_registration_with_subscription_total",
|
||||||
|
labels={"status": "failed_payment_setup"})
|
||||||
|
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail="Payment setup failed"
|
||||||
|
)
|
||||||
|
|
||||||
|
# CRITICAL: Check if SetupIntent requires 3DS authentication
|
||||||
|
if payment_setup_result.get('requires_action'):
|
||||||
|
# NEW ARCHITECTURE: Return SetupIntent data WITHOUT creating user
|
||||||
|
logger.info("Payment setup requires SetupIntent authentication - deferring user creation",
|
||||||
|
email=user_data.email,
|
||||||
|
action_type=payment_setup_result.get('action_type'),
|
||||||
|
setup_intent_id=payment_setup_result.get('setup_intent_id'))
|
||||||
|
|
||||||
|
if metrics:
|
||||||
|
metrics.increment_counter("enhanced_registration_with_subscription_total",
|
||||||
|
labels={"status": "requires_3ds"})
|
||||||
|
|
||||||
|
# Return SetupIntent data for frontend to handle 3DS
|
||||||
|
# NO user created yet, NO tokens returned
|
||||||
|
return {
|
||||||
|
"requires_action": True,
|
||||||
|
"action_type": payment_setup_result.get('action_type'),
|
||||||
|
"client_secret": payment_setup_result.get('client_secret'),
|
||||||
|
"setup_intent_id": payment_setup_result.get('setup_intent_id'),
|
||||||
|
"customer_id": payment_setup_result.get('customer_id'),
|
||||||
|
"payment_customer_id": payment_setup_result.get('payment_customer_id'),
|
||||||
|
"plan_id": payment_setup_result.get('plan_id'),
|
||||||
|
"payment_method_id": payment_setup_result.get('payment_method_id'),
|
||||||
|
"trial_period_days": payment_setup_result.get('trial_period_days'),
|
||||||
|
"email": payment_setup_result.get('email'),
|
||||||
|
"full_name": payment_setup_result.get('full_name'),
|
||||||
|
"billing_interval": payment_setup_result.get('billing_interval'),
|
||||||
|
"coupon_code": payment_setup_result.get('coupon_code'),
|
||||||
|
"message": payment_setup_result.get('message') or "Payment verification required before account creation"
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
# No 3DS required - proceed with user creation
|
||||||
|
logger.info("No SetupIntent required - proceeding with user creation",
|
||||||
|
email=user_data.email)
|
||||||
|
else:
|
||||||
|
# No subscription data provided - proceed with user creation
|
||||||
|
logger.info("No subscription data provided - proceeding with user creation",
|
||||||
|
email=user_data.email)
|
||||||
|
|
||||||
|
# Step 2: Create user (ONLY if no SetupIntent required)
|
||||||
|
logger.info("Step 2: Creating user after payment verification",
|
||||||
|
email=user_data.email)
|
||||||
|
|
||||||
result = await auth_service.register_user(user_data)
|
result = await auth_service.register_user(user_data)
|
||||||
user_id = result.user.id
|
user_id = result.user.id
|
||||||
|
|
||||||
logger.info("User created successfully", user_id=user_id)
|
logger.info("User created successfully",
|
||||||
|
user_id=user_id,
|
||||||
|
email=user_data.email)
|
||||||
|
|
||||||
# Step 2: Create subscription via tenant service (if subscription data provided)
|
# Step 3: If subscription was created (no 3DS), store in onboarding progress
|
||||||
subscription_id = None
|
|
||||||
if user_data.subscription_plan and user_data.payment_method_id:
|
if user_data.subscription_plan and user_data.payment_method_id:
|
||||||
logger.info("Step 2: Creating tenant-independent subscription",
|
subscription_id = payment_setup_result.get("subscription_id")
|
||||||
user_id=user_id,
|
|
||||||
plan=user_data.subscription_plan)
|
if subscription_id:
|
||||||
|
|
||||||
subscription_result = await auth_service.create_subscription_via_tenant_service(
|
|
||||||
user_id=user_id,
|
|
||||||
plan_id=user_data.subscription_plan,
|
|
||||||
payment_method_id=user_data.payment_method_id,
|
|
||||||
billing_cycle=user_data.billing_cycle or "monthly",
|
|
||||||
coupon_code=user_data.coupon_code
|
|
||||||
)
|
|
||||||
|
|
||||||
if subscription_result:
|
|
||||||
subscription_id = subscription_result.get("subscription_id")
|
|
||||||
logger.info("Tenant-independent subscription created successfully",
|
logger.info("Tenant-independent subscription created successfully",
|
||||||
user_id=user_id,
|
user_id=user_id,
|
||||||
subscription_id=subscription_id)
|
subscription_id=subscription_id)
|
||||||
|
|
||||||
# Step 3: Store subscription data in onboarding progress
|
# Store subscription data in onboarding progress
|
||||||
logger.info("Step 3: Storing subscription data in onboarding progress",
|
|
||||||
user_id=user_id)
|
|
||||||
|
|
||||||
# Update onboarding progress with subscription data
|
|
||||||
await auth_service.save_subscription_to_onboarding_progress(
|
await auth_service.save_subscription_to_onboarding_progress(
|
||||||
user_id=user_id,
|
user_id=user_id,
|
||||||
subscription_id=subscription_id,
|
subscription_id=subscription_id,
|
||||||
registration_data=user_data
|
registration_data=user_data
|
||||||
)
|
)
|
||||||
|
|
||||||
logger.info("Subscription data stored in onboarding progress",
|
logger.info("Subscription data stored in onboarding progress",
|
||||||
user_id=user_id)
|
user_id=user_id)
|
||||||
|
|
||||||
|
result.subscription_id = subscription_id
|
||||||
else:
|
else:
|
||||||
logger.warning("Subscription creation failed, but user registration succeeded",
|
logger.warning("No subscription ID returned, but user registration succeeded",
|
||||||
user_id=user_id)
|
user_id=user_id)
|
||||||
else:
|
|
||||||
logger.info("No subscription data provided, skipping subscription creation",
|
# Record successful registration
|
||||||
user_id=user_id)
|
if metrics:
|
||||||
|
metrics.increment_counter("enhanced_registration_with_subscription_total",
|
||||||
|
labels={"status": "success"})
|
||||||
|
|
||||||
|
logger.info("Registration with subscription completed successfully using secure architecture",
|
||||||
|
user_id=user_id,
|
||||||
|
email=user_data.email,
|
||||||
|
subscription_id=result.subscription_id)
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
if metrics:
|
||||||
|
error_type = "validation_error" if "validation" in str(e).lower() else "conflict" if "conflict" in str(e).lower() else "failed"
|
||||||
|
metrics.increment_counter("enhanced_registration_with_subscription_total",
|
||||||
|
labels={"status": error_type})
|
||||||
|
|
||||||
|
logger.error("Registration with subscription system error using secure architecture",
|
||||||
|
email=user_data.email,
|
||||||
|
error=str(e),
|
||||||
|
exc_info=True)
|
||||||
|
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail="Registration with subscription failed: " + str(e)
|
||||||
|
)
|
||||||
|
|
||||||
# Record successful registration
|
# Record successful registration
|
||||||
if metrics:
|
if metrics:
|
||||||
@@ -206,6 +302,30 @@ async def register_with_subscription(
|
|||||||
|
|
||||||
# Add subscription_id to the response
|
# Add subscription_id to the response
|
||||||
result.subscription_id = subscription_id
|
result.subscription_id = subscription_id
|
||||||
|
|
||||||
|
# Check if subscription creation requires 3DS/SetupIntent authentication
|
||||||
|
if subscription_result and subscription_result.get('requires_action'):
|
||||||
|
result.requires_action = subscription_result.get('requires_action')
|
||||||
|
result.action_type = subscription_result.get('action_type')
|
||||||
|
result.client_secret = subscription_result.get('client_secret')
|
||||||
|
result.setup_intent_id = subscription_result.get('setup_intent_id')
|
||||||
|
result.payment_intent_id = subscription_result.get('payment_intent_id') # Legacy, deprecated
|
||||||
|
|
||||||
|
# Include data needed for post-3DS subscription completion
|
||||||
|
result.customer_id = subscription_result.get('customer_id')
|
||||||
|
result.plan_id = user_data.subscription_plan
|
||||||
|
result.payment_method_id = user_data.payment_method_id
|
||||||
|
result.trial_period_days = subscription_result.get('trial_period_days')
|
||||||
|
result.user_id = user_id
|
||||||
|
result.billing_interval = user_data.billing_cycle or "monthly"
|
||||||
|
result.message = subscription_result.get('message')
|
||||||
|
|
||||||
|
logger.info("Registration requires SetupIntent authentication",
|
||||||
|
user_id=user_id,
|
||||||
|
requires_action=result.requires_action,
|
||||||
|
action_type=result.action_type,
|
||||||
|
setup_intent_id=result.setup_intent_id)
|
||||||
|
|
||||||
return result
|
return result
|
||||||
|
|
||||||
except HTTPException as e:
|
except HTTPException as e:
|
||||||
@@ -675,6 +795,142 @@ async def reset_password(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/api/v1/auth/complete-registration-after-setup-intent")
|
||||||
|
@track_execution_time("registration_completion_duration_seconds", "auth-service")
|
||||||
|
async def complete_registration_after_setup_intent(
|
||||||
|
completion_data: SetupIntentCompletionData,
|
||||||
|
request: Request,
|
||||||
|
auth_service: EnhancedAuthService = Depends(get_auth_service)
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Complete user registration after SetupIntent confirmation
|
||||||
|
|
||||||
|
This endpoint is called by the frontend after 3DS authentication is complete.
|
||||||
|
It ensures users are only created after payment verification.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
completion_data: Data from frontend including SetupIntent ID and user info
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
TokenResponse with access_token, refresh_token, and user data
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
HTTPException: 400 if SetupIntent not succeeded
|
||||||
|
HTTPException: 500 if registration fails
|
||||||
|
"""
|
||||||
|
metrics = get_metrics_collector(request)
|
||||||
|
|
||||||
|
logger.info("Completing registration after SetupIntent confirmation",
|
||||||
|
email=completion_data.email,
|
||||||
|
setup_intent_id=completion_data.setup_intent_id)
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Step 1: Verify SetupIntent using tenant service orchestration
|
||||||
|
logger.info("Step 1: Verifying SetupIntent using orchestration service",
|
||||||
|
setup_intent_id=completion_data.setup_intent_id)
|
||||||
|
|
||||||
|
verification_result = await auth_service.verify_setup_intent_via_tenant_service(
|
||||||
|
completion_data.setup_intent_id
|
||||||
|
)
|
||||||
|
|
||||||
|
if not verification_result or verification_result.get('status') != 'succeeded':
|
||||||
|
status_code = status.HTTP_400_BAD_REQUEST
|
||||||
|
detail = f"SetupIntent not succeeded: {verification_result.get('status') if verification_result else 'unknown'}"
|
||||||
|
|
||||||
|
logger.warning("SetupIntent verification failed via orchestration service",
|
||||||
|
email=completion_data.email,
|
||||||
|
setup_intent_id=completion_data.setup_intent_id,
|
||||||
|
status=verification_result.get('status') if verification_result else 'unknown')
|
||||||
|
|
||||||
|
if metrics:
|
||||||
|
metrics.increment_counter("registration_completion_total", labels={"status": "failed_verification"})
|
||||||
|
|
||||||
|
raise HTTPException(status_code=status_code, detail=detail)
|
||||||
|
|
||||||
|
logger.info("SetupIntent verification succeeded via orchestration service",
|
||||||
|
setup_intent_id=completion_data.setup_intent_id)
|
||||||
|
|
||||||
|
# Step 2: Create user (ONLY after payment verification)
|
||||||
|
logger.info("Step 2: Creating user after successful payment verification",
|
||||||
|
email=completion_data.email)
|
||||||
|
|
||||||
|
user_data = UserRegistration(
|
||||||
|
email=completion_data.email,
|
||||||
|
password=completion_data.password,
|
||||||
|
full_name=completion_data.full_name,
|
||||||
|
subscription_plan=completion_data.plan_id,
|
||||||
|
payment_method_id=completion_data.payment_method_id,
|
||||||
|
billing_cycle=completion_data.billing_interval,
|
||||||
|
coupon_code=completion_data.coupon_code
|
||||||
|
)
|
||||||
|
|
||||||
|
registration_result = await auth_service.register_user(user_data)
|
||||||
|
|
||||||
|
logger.info("User created successfully after payment verification",
|
||||||
|
user_id=registration_result.user.id,
|
||||||
|
email=completion_data.email)
|
||||||
|
|
||||||
|
# Step 3: Create subscription (now that user exists)
|
||||||
|
logger.info("Step 3: Creating subscription for verified user",
|
||||||
|
user_id=registration_result.user.id,
|
||||||
|
plan_id=completion_data.plan_id)
|
||||||
|
|
||||||
|
subscription_result = await auth_service.create_subscription_via_tenant_service(
|
||||||
|
user_id=registration_result.user.id,
|
||||||
|
plan_id=completion_data.plan_id,
|
||||||
|
payment_method_id=completion_data.payment_method_id,
|
||||||
|
billing_cycle=completion_data.billing_interval,
|
||||||
|
coupon_code=completion_data.coupon_code
|
||||||
|
)
|
||||||
|
|
||||||
|
if not subscription_result or not subscription_result.get('success'):
|
||||||
|
logger.error("Subscription creation failed after successful user registration",
|
||||||
|
user_id=registration_result.user.id,
|
||||||
|
error="Subscription creation returned no success")
|
||||||
|
|
||||||
|
if metrics:
|
||||||
|
metrics.increment_counter("registration_completion_total", labels={"status": "failed_subscription"})
|
||||||
|
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail="Subscription creation failed after user registration"
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info("Subscription created successfully",
|
||||||
|
user_id=registration_result.user.id,
|
||||||
|
subscription_id=subscription_result.get('subscription_id'))
|
||||||
|
|
||||||
|
# Step 4: Return tokens and subscription data
|
||||||
|
registration_result.subscription_id = subscription_result.get('subscription_id')
|
||||||
|
|
||||||
|
if metrics:
|
||||||
|
metrics.increment_counter("registration_completion_total", labels={"status": "success"})
|
||||||
|
|
||||||
|
logger.info("Registration completed successfully after SetupIntent confirmation",
|
||||||
|
user_id=registration_result.user.id,
|
||||||
|
email=completion_data.email,
|
||||||
|
subscription_id=subscription_result.get('subscription_id'))
|
||||||
|
|
||||||
|
return registration_result
|
||||||
|
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
if metrics:
|
||||||
|
metrics.increment_counter("registration_completion_total", labels={"status": "error"})
|
||||||
|
|
||||||
|
logger.error("Registration completion system error",
|
||||||
|
email=completion_data.email,
|
||||||
|
setup_intent_id=completion_data.setup_intent_id,
|
||||||
|
error=str(e),
|
||||||
|
exc_info=True)
|
||||||
|
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail="Registration completion failed: " + str(e)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/api/v1/auth/health")
|
@router.get("/api/v1/auth/health")
|
||||||
async def health_check():
|
async def health_check():
|
||||||
"""Health check endpoint for enhanced auth service"""
|
"""Health check endpoint for enhanced auth service"""
|
||||||
|
|||||||
@@ -516,7 +516,7 @@ async def update_user_tenant(
|
|||||||
tenant_id=tenant_id)
|
tenant_id=tenant_id)
|
||||||
|
|
||||||
user_service = UserService(db)
|
user_service = UserService(db)
|
||||||
user = await user_service.get_user_by_id(uuid.UUID(user_id))
|
user = await user_service.get_user_by_id(uuid.UUID(user_id), session=db)
|
||||||
|
|
||||||
if not user:
|
if not user:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
|
|||||||
@@ -78,6 +78,20 @@ class TokenResponse(BaseModel):
|
|||||||
expires_in: int = 3600 # seconds
|
expires_in: int = 3600 # seconds
|
||||||
user: Optional[UserData] = None
|
user: Optional[UserData] = None
|
||||||
subscription_id: Optional[str] = Field(None, description="Subscription ID if created during registration")
|
subscription_id: Optional[str] = Field(None, description="Subscription ID if created during registration")
|
||||||
|
# Payment action fields (3DS, SetupIntent, etc.)
|
||||||
|
requires_action: Optional[bool] = Field(None, description="Whether payment action is required (3DS, SetupIntent confirmation)")
|
||||||
|
action_type: Optional[str] = Field(None, description="Type of action required (setup_intent_confirmation, payment_intent_confirmation)")
|
||||||
|
client_secret: Optional[str] = Field(None, description="Client secret for payment confirmation")
|
||||||
|
payment_intent_id: Optional[str] = Field(None, description="Payment intent ID for 3DS authentication")
|
||||||
|
setup_intent_id: Optional[str] = Field(None, description="SetupIntent ID for payment method verification")
|
||||||
|
customer_id: Optional[str] = Field(None, description="Stripe customer ID")
|
||||||
|
# Additional fields for post-confirmation subscription completion
|
||||||
|
plan_id: Optional[str] = Field(None, description="Subscription plan ID")
|
||||||
|
payment_method_id: Optional[str] = Field(None, description="Payment method ID")
|
||||||
|
trial_period_days: Optional[int] = Field(None, description="Trial period in days")
|
||||||
|
user_id: Optional[str] = Field(None, description="User ID for post-confirmation processing")
|
||||||
|
billing_interval: Optional[str] = Field(None, description="Billing interval (monthly, yearly)")
|
||||||
|
message: Optional[str] = Field(None, description="Additional message about payment action required")
|
||||||
|
|
||||||
class Config:
|
class Config:
|
||||||
schema_extra = {
|
schema_extra = {
|
||||||
@@ -95,7 +109,13 @@ class TokenResponse(BaseModel):
|
|||||||
"created_at": "2025-07-22T10:00:00Z",
|
"created_at": "2025-07-22T10:00:00Z",
|
||||||
"role": "user"
|
"role": "user"
|
||||||
},
|
},
|
||||||
"subscription_id": "sub_1234567890"
|
"subscription_id": "sub_1234567890",
|
||||||
|
"requires_action": True,
|
||||||
|
"action_type": "setup_intent_confirmation",
|
||||||
|
"client_secret": "seti_1234_secret_5678",
|
||||||
|
"payment_intent_id": None,
|
||||||
|
"setup_intent_id": "seti_1234567890",
|
||||||
|
"customer_id": "cus_1234567890"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -861,6 +861,94 @@ class EnhancedAuthService:
|
|||||||
error=str(e))
|
error=str(e))
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
async def create_registration_payment_setup_via_tenant_service(
|
||||||
|
self,
|
||||||
|
user_data: UserRegistration
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Create registration payment setup via tenant service orchestration
|
||||||
|
|
||||||
|
This method uses the tenant service's orchestration service to create
|
||||||
|
payment customer and SetupIntent in a coordinated workflow for the
|
||||||
|
secure architecture where users are only created after payment verification.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user_data: User registration data (email, full_name, etc.)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary with payment setup results including SetupIntent if required
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
from shared.clients.tenant_client import TenantServiceClient
|
||||||
|
from shared.config.base import BaseServiceSettings
|
||||||
|
|
||||||
|
tenant_client = TenantServiceClient(BaseServiceSettings())
|
||||||
|
|
||||||
|
# Prepare user data for tenant service orchestration
|
||||||
|
user_data_for_tenant = {
|
||||||
|
"email": user_data.email,
|
||||||
|
"full_name": user_data.full_name,
|
||||||
|
"payment_method_id": user_data.payment_method_id,
|
||||||
|
"plan_id": user_data.subscription_plan or "professional",
|
||||||
|
"billing_cycle": user_data.billing_cycle or "monthly",
|
||||||
|
"coupon_code": user_data.coupon_code
|
||||||
|
}
|
||||||
|
|
||||||
|
# Call tenant service orchestration endpoint
|
||||||
|
result = await tenant_client.create_registration_payment_setup(user_data_for_tenant)
|
||||||
|
|
||||||
|
logger.info("Registration payment setup completed via tenant service orchestration",
|
||||||
|
email=user_data.email,
|
||||||
|
requires_action=result.get('requires_action'),
|
||||||
|
setup_intent_id=result.get('setup_intent_id'))
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("Registration payment setup via tenant service failed",
|
||||||
|
email=user_data.email,
|
||||||
|
error=str(e),
|
||||||
|
exc_info=True)
|
||||||
|
raise
|
||||||
|
|
||||||
|
async def verify_setup_intent_via_tenant_service(
|
||||||
|
self,
|
||||||
|
setup_intent_id: str
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Verify SetupIntent via tenant service orchestration
|
||||||
|
|
||||||
|
This method uses the tenant service's orchestration service to verify
|
||||||
|
SetupIntent status before proceeding with user creation.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
setup_intent_id: SetupIntent ID to verify
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary with SetupIntent verification result
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
from shared.clients.tenant_client import TenantServiceClient
|
||||||
|
from shared.config.base import BaseServiceSettings
|
||||||
|
|
||||||
|
tenant_client = TenantServiceClient(BaseServiceSettings())
|
||||||
|
|
||||||
|
# Call tenant service orchestration endpoint
|
||||||
|
result = await tenant_client.verify_setup_intent_for_registration(setup_intent_id)
|
||||||
|
|
||||||
|
logger.info("SetupIntent verified via tenant service orchestration",
|
||||||
|
setup_intent_id=setup_intent_id,
|
||||||
|
status=result.get('status'))
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("SetupIntent verification via tenant service failed",
|
||||||
|
setup_intent_id=setup_intent_id,
|
||||||
|
error=str(e),
|
||||||
|
exc_info=True)
|
||||||
|
raise
|
||||||
|
|
||||||
async def get_user_data_for_tenant_service(self, user_id: str) -> Dict[str, Any]:
|
async def get_user_data_for_tenant_service(self, user_id: str) -> Dict[str, Any]:
|
||||||
"""
|
"""
|
||||||
Get user data formatted for tenant service calls
|
Get user data formatted for tenant service calls
|
||||||
@@ -894,6 +982,101 @@ class EnhancedAuthService:
|
|||||||
error=str(e))
|
error=str(e))
|
||||||
raise
|
raise
|
||||||
|
|
||||||
|
async def create_payment_customer_for_registration(
|
||||||
|
self,
|
||||||
|
user_data: UserRegistration
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Create payment customer for registration (BEFORE user creation)
|
||||||
|
|
||||||
|
This method creates a payment customer in the tenant service
|
||||||
|
without requiring a user to exist first. This supports the
|
||||||
|
secure architecture where users are only created after payment verification.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user_data: User registration data
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary with payment customer creation result
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
Exception: If payment customer creation fails
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
from shared.clients.tenant_client import TenantServiceClient
|
||||||
|
from app.core.config import settings
|
||||||
|
|
||||||
|
tenant_client = TenantServiceClient(settings)
|
||||||
|
|
||||||
|
# Prepare user data for tenant service (without user_id)
|
||||||
|
user_data_for_tenant = {
|
||||||
|
"email": user_data.email,
|
||||||
|
"full_name": user_data.full_name,
|
||||||
|
"name": user_data.full_name
|
||||||
|
}
|
||||||
|
|
||||||
|
# Call tenant service to create payment customer
|
||||||
|
payment_result = await tenant_client.create_payment_customer(
|
||||||
|
user_data_for_tenant,
|
||||||
|
user_data.payment_method_id
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info("Payment customer created for registration (pre-user creation)",
|
||||||
|
email=user_data.email,
|
||||||
|
payment_customer_id=payment_result.get("payment_customer_id") if payment_result else "unknown")
|
||||||
|
|
||||||
|
return payment_result
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("Payment customer creation failed for registration",
|
||||||
|
email=user_data.email,
|
||||||
|
error=str(e),
|
||||||
|
exc_info=True)
|
||||||
|
raise
|
||||||
|
|
||||||
|
async def verify_setup_intent(
|
||||||
|
self,
|
||||||
|
setup_intent_id: str
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Verify SetupIntent status with payment provider
|
||||||
|
|
||||||
|
This method checks if a SetupIntent has been successfully confirmed
|
||||||
|
(either automatically or via 3DS authentication).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
setup_intent_id: SetupIntent ID to verify
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary with SetupIntent verification result
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
Exception: If verification fails
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
from shared.clients.tenant_client import TenantServiceClient
|
||||||
|
from app.core.config import settings
|
||||||
|
|
||||||
|
tenant_client = TenantServiceClient(settings)
|
||||||
|
|
||||||
|
# Call tenant service to verify SetupIntent
|
||||||
|
verification_result = await tenant_client.verify_setup_intent(
|
||||||
|
setup_intent_id
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info("SetupIntent verification result",
|
||||||
|
setup_intent_id=setup_intent_id,
|
||||||
|
status=verification_result.get("status") if verification_result else "unknown")
|
||||||
|
|
||||||
|
return verification_result
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("SetupIntent verification failed",
|
||||||
|
setup_intent_id=setup_intent_id,
|
||||||
|
error=str(e),
|
||||||
|
exc_info=True)
|
||||||
|
raise
|
||||||
|
|
||||||
async def save_subscription_to_onboarding_progress(
|
async def save_subscription_to_onboarding_progress(
|
||||||
self,
|
self,
|
||||||
user_id: str,
|
user_id: str,
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ Updated to use repository pattern with dependency injection and improved error h
|
|||||||
|
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from typing import Dict, Any, List, Optional
|
from typing import Dict, Any, List, Optional
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from fastapi import HTTPException, status
|
from fastapi import HTTPException, status
|
||||||
import structlog
|
import structlog
|
||||||
|
|
||||||
@@ -27,22 +28,27 @@ class EnhancedUserService:
|
|||||||
"""Initialize service with database manager"""
|
"""Initialize service with database manager"""
|
||||||
self.database_manager = database_manager
|
self.database_manager = database_manager
|
||||||
|
|
||||||
async def get_user_by_id(self, user_id: str) -> Optional[UserResponse]:
|
async def get_user_by_id(self, user_id: str, session: Optional[AsyncSession] = None) -> Optional[UserResponse]:
|
||||||
"""Get user by ID using repository pattern"""
|
"""Get user by ID using repository pattern"""
|
||||||
try:
|
try:
|
||||||
async with self.database_manager.get_session() as session:
|
if session:
|
||||||
|
# Use provided session (for direct session injection)
|
||||||
user_repo = UserRepository(User, session)
|
user_repo = UserRepository(User, session)
|
||||||
|
else:
|
||||||
user = await user_repo.get_by_id(user_id)
|
# Use database manager to get session
|
||||||
if not user:
|
async with self.database_manager.get_session() as session:
|
||||||
return None
|
user_repo = UserRepository(User, session)
|
||||||
|
|
||||||
return UserResponse(
|
user = await user_repo.get_by_id(user_id)
|
||||||
id=str(user.id),
|
if not user:
|
||||||
email=user.email,
|
return None
|
||||||
full_name=user.full_name,
|
|
||||||
is_active=user.is_active,
|
return UserResponse(
|
||||||
is_verified=user.is_verified,
|
id=str(user.id),
|
||||||
|
email=user.email,
|
||||||
|
full_name=user.full_name,
|
||||||
|
is_active=user.is_active,
|
||||||
|
is_verified=user.is_verified,
|
||||||
created_at=user.created_at,
|
created_at=user.created_at,
|
||||||
role=user.role,
|
role=user.role,
|
||||||
phone=getattr(user, 'phone', None),
|
phone=getattr(user, 'phone', None),
|
||||||
|
|||||||
@@ -228,8 +228,8 @@ CREATE TABLE tenants (
|
|||||||
|
|
||||||
-- Subscription
|
-- Subscription
|
||||||
subscription_tier VARCHAR(50) DEFAULT 'free', -- free, pro, enterprise
|
subscription_tier VARCHAR(50) DEFAULT 'free', -- free, pro, enterprise
|
||||||
stripe_customer_id VARCHAR(255), -- Stripe customer ID
|
customer_id VARCHAR(255), -- Stripe customer ID
|
||||||
stripe_subscription_id VARCHAR(255), -- Stripe subscription ID
|
subscription_id VARCHAR(255), -- Stripe subscription ID
|
||||||
|
|
||||||
-- 🆕 Enterprise hierarchy fields (NEW)
|
-- 🆕 Enterprise hierarchy fields (NEW)
|
||||||
parent_tenant_id UUID REFERENCES tenants(id) ON DELETE RESTRICT,
|
parent_tenant_id UUID REFERENCES tenants(id) ON DELETE RESTRICT,
|
||||||
@@ -271,8 +271,8 @@ CREATE INDEX idx_tenants_hierarchy_path ON tenants(hierarchy_path);
|
|||||||
CREATE TABLE tenant_subscriptions (
|
CREATE TABLE tenant_subscriptions (
|
||||||
id UUID PRIMARY KEY,
|
id UUID PRIMARY KEY,
|
||||||
tenant_id UUID REFERENCES tenants(id) ON DELETE CASCADE,
|
tenant_id UUID REFERENCES tenants(id) ON DELETE CASCADE,
|
||||||
stripe_subscription_id VARCHAR(255) UNIQUE,
|
subscription_id VARCHAR(255) UNIQUE,
|
||||||
stripe_customer_id VARCHAR(255),
|
customer_id VARCHAR(255),
|
||||||
|
|
||||||
-- Plan details
|
-- Plan details
|
||||||
plan_tier VARCHAR(50) NOT NULL, -- free, pro, enterprise
|
plan_tier VARCHAR(50) NOT NULL, -- free, pro, enterprise
|
||||||
@@ -486,7 +486,7 @@ CREATE TABLE tenant_audit_log (
|
|||||||
```sql
|
```sql
|
||||||
CREATE INDEX idx_tenants_status ON tenants(status);
|
CREATE INDEX idx_tenants_status ON tenants(status);
|
||||||
CREATE INDEX idx_tenants_subscription_tier ON tenants(subscription_tier);
|
CREATE INDEX idx_tenants_subscription_tier ON tenants(subscription_tier);
|
||||||
CREATE INDEX idx_subscriptions_stripe ON tenant_subscriptions(stripe_subscription_id);
|
CREATE INDEX idx_subscriptions_stripe ON tenant_subscriptions(subscription_id);
|
||||||
CREATE INDEX idx_subscriptions_status ON tenant_subscriptions(tenant_id, status);
|
CREATE INDEX idx_subscriptions_status ON tenant_subscriptions(tenant_id, status);
|
||||||
CREATE INDEX idx_members_tenant ON tenant_members(tenant_id);
|
CREATE INDEX idx_members_tenant ON tenant_members(tenant_id);
|
||||||
CREATE INDEX idx_members_user ON tenant_members(user_id);
|
CREATE INDEX idx_members_user ON tenant_members(user_id);
|
||||||
@@ -555,7 +555,7 @@ async def create_tenant_with_subscription(
|
|||||||
}] if tenant.tax_id else None
|
}] if tenant.tax_id else None
|
||||||
)
|
)
|
||||||
|
|
||||||
tenant.stripe_customer_id = stripe_customer.id
|
tenant.customer_id = stripe_customer.id
|
||||||
|
|
||||||
# Attach payment method if provided
|
# Attach payment method if provided
|
||||||
if payment_method_id:
|
if payment_method_id:
|
||||||
@@ -587,13 +587,13 @@ async def create_tenant_with_subscription(
|
|||||||
|
|
||||||
stripe_subscription = stripe.Subscription.create(**subscription_params)
|
stripe_subscription = stripe.Subscription.create(**subscription_params)
|
||||||
|
|
||||||
tenant.stripe_subscription_id = stripe_subscription.id
|
tenant.subscription_id = stripe_subscription.id
|
||||||
|
|
||||||
# Create subscription record
|
# Create subscription record
|
||||||
subscription = TenantSubscription(
|
subscription = TenantSubscription(
|
||||||
tenant_id=tenant.id,
|
tenant_id=tenant.id,
|
||||||
stripe_subscription_id=stripe_subscription.id,
|
subscription_id=stripe_subscription.id,
|
||||||
stripe_customer_id=stripe_customer.id,
|
customer_id=stripe_customer.id,
|
||||||
plan_tier=plan_tier,
|
plan_tier=plan_tier,
|
||||||
plan_interval='month',
|
plan_interval='month',
|
||||||
plan_amount=get_plan_amount(plan_tier),
|
plan_amount=get_plan_amount(plan_tier),
|
||||||
@@ -705,11 +705,11 @@ async def update_subscription(
|
|||||||
new_price_id = get_stripe_price_id(new_plan_tier, subscription.plan_interval)
|
new_price_id = get_stripe_price_id(new_plan_tier, subscription.plan_interval)
|
||||||
|
|
||||||
# Update Stripe subscription
|
# Update Stripe subscription
|
||||||
stripe_subscription = stripe.Subscription.retrieve(subscription.stripe_subscription_id)
|
stripe_subscription = stripe.Subscription.retrieve(subscription.subscription_id)
|
||||||
|
|
||||||
# Update subscription items (Stripe handles proration automatically)
|
# Update subscription items (Stripe handles proration automatically)
|
||||||
stripe_subscription = stripe.Subscription.modify(
|
stripe_subscription = stripe.Subscription.modify(
|
||||||
subscription.stripe_subscription_id,
|
subscription.subscription_id,
|
||||||
items=[{
|
items=[{
|
||||||
'id': stripe_subscription['items']['data'][0].id,
|
'id': stripe_subscription['items']['data'][0].id,
|
||||||
'price': new_price_id
|
'price': new_price_id
|
||||||
@@ -828,7 +828,7 @@ async def handle_subscription_updated(stripe_subscription: dict):
|
|||||||
tenant_id = UUID(stripe_subscription['metadata'].get('tenant_id'))
|
tenant_id = UUID(stripe_subscription['metadata'].get('tenant_id'))
|
||||||
|
|
||||||
subscription = await db.query(TenantSubscription).filter(
|
subscription = await db.query(TenantSubscription).filter(
|
||||||
TenantSubscription.stripe_subscription_id == stripe_subscription['id']
|
TenantSubscription.subscription_id == stripe_subscription['id']
|
||||||
).first()
|
).first()
|
||||||
|
|
||||||
if subscription:
|
if subscription:
|
||||||
@@ -846,7 +846,7 @@ async def handle_payment_failed(stripe_invoice: dict):
|
|||||||
customer_id = stripe_invoice['customer']
|
customer_id = stripe_invoice['customer']
|
||||||
|
|
||||||
tenant = await db.query(Tenant).filter(
|
tenant = await db.query(Tenant).filter(
|
||||||
Tenant.stripe_customer_id == customer_id
|
Tenant.customer_id == customer_id
|
||||||
).first()
|
).first()
|
||||||
|
|
||||||
if tenant:
|
if tenant:
|
||||||
@@ -923,9 +923,9 @@ async def upgrade_tenant_to_enterprise(
|
|||||||
import stripe
|
import stripe
|
||||||
stripe.api_key = os.getenv('STRIPE_SECRET_KEY')
|
stripe.api_key = os.getenv('STRIPE_SECRET_KEY')
|
||||||
|
|
||||||
stripe_subscription = stripe.Subscription.retrieve(subscription.stripe_subscription_id)
|
stripe_subscription = stripe.Subscription.retrieve(subscription.subscription_id)
|
||||||
stripe.Subscription.modify(
|
stripe.Subscription.modify(
|
||||||
subscription.stripe_subscription_id,
|
subscription.subscription_id,
|
||||||
items=[{
|
items=[{
|
||||||
'id': stripe_subscription['items']['data'][0].id,
|
'id': stripe_subscription['items']['data'][0].id,
|
||||||
'price': new_price_id
|
'price': new_price_id
|
||||||
@@ -1052,8 +1052,8 @@ async def add_child_outlet_to_parent(
|
|||||||
# 3. Create linked subscription (child shares parent subscription)
|
# 3. Create linked subscription (child shares parent subscription)
|
||||||
child_subscription = TenantSubscription(
|
child_subscription = TenantSubscription(
|
||||||
tenant_id=child_tenant.id,
|
tenant_id=child_tenant.id,
|
||||||
stripe_subscription_id=None, # Linked to parent, no separate billing
|
subscription_id=None, # Linked to parent, no separate billing
|
||||||
stripe_customer_id=parent.stripe_customer_id, # Same customer
|
customer_id=parent.customer_id, # Same customer
|
||||||
plan_tier='enterprise',
|
plan_tier='enterprise',
|
||||||
plan_interval='month',
|
plan_interval='month',
|
||||||
plan_amount=Decimal('0.00'), # No additional charge
|
plan_amount=Decimal('0.00'), # No additional charge
|
||||||
|
|||||||
@@ -200,8 +200,8 @@ async def clone_demo_data(
|
|||||||
session_time,
|
session_time,
|
||||||
"next_billing_date"
|
"next_billing_date"
|
||||||
),
|
),
|
||||||
stripe_subscription_id=subscription_data.get('stripe_subscription_id'),
|
subscription_id=subscription_data.get('stripe_subscription_id'),
|
||||||
stripe_customer_id=subscription_data.get('stripe_customer_id'),
|
customer_id=subscription_data.get('stripe_customer_id'),
|
||||||
cancelled_at=parse_date_field(
|
cancelled_at=parse_date_field(
|
||||||
subscription_data.get('cancelled_at'),
|
subscription_data.get('cancelled_at'),
|
||||||
session_time,
|
session_time,
|
||||||
|
|||||||
@@ -867,7 +867,7 @@ async def register_with_subscription(
|
|||||||
return {
|
return {
|
||||||
"success": True,
|
"success": True,
|
||||||
"message": "Registration and subscription created successfully",
|
"message": "Registration and subscription created successfully",
|
||||||
"data": result
|
**result
|
||||||
}
|
}
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error("Failed to register with subscription", error=str(e))
|
logger.error("Failed to register with subscription", error=str(e))
|
||||||
@@ -924,7 +924,7 @@ async def create_subscription_endpoint(
|
|||||||
return {
|
return {
|
||||||
"success": True,
|
"success": True,
|
||||||
"message": "Subscription created successfully",
|
"message": "Subscription created successfully",
|
||||||
"data": result
|
**result
|
||||||
}
|
}
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -975,16 +975,30 @@ async def create_subscription_for_registration(
|
|||||||
request.billing_interval,
|
request.billing_interval,
|
||||||
request.coupon_code
|
request.coupon_code
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Check if result requires SetupIntent confirmation (3DS)
|
||||||
|
if result.get('requires_action'):
|
||||||
|
logger.info("Subscription creation requires SetupIntent confirmation",
|
||||||
|
user_id=request.user_data.get('user_id'),
|
||||||
|
action_type=result.get('action_type'),
|
||||||
|
setup_intent_id=result.get('setup_intent_id'))
|
||||||
|
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"message": "Payment method verification required",
|
||||||
|
**result # Spread all result fields to top level for frontend compatibility
|
||||||
|
}
|
||||||
|
|
||||||
|
# Normal subscription creation (no 3DS)
|
||||||
logger.info("Tenant-independent subscription created successfully",
|
logger.info("Tenant-independent subscription created successfully",
|
||||||
user_id=request.user_data.get('user_id'),
|
user_id=request.user_data.get('user_id'),
|
||||||
subscription_id=result["subscription_id"],
|
subscription_id=result.get("subscription_id"),
|
||||||
plan_id=request.plan_id)
|
plan_id=request.plan_id)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"success": True,
|
"success": True,
|
||||||
"message": "Tenant-independent subscription created successfully",
|
"message": "Tenant-independent subscription created successfully",
|
||||||
"data": result
|
**result
|
||||||
}
|
}
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -998,6 +1012,136 @@ async def create_subscription_for_registration(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/api/v1/subscriptions/complete-after-setup-intent")
|
||||||
|
async def complete_subscription_after_setup_intent(
|
||||||
|
request: dict = Body(..., description="Completion request with setup_intent_id"),
|
||||||
|
db: AsyncSession = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Complete subscription creation after SetupIntent confirmation
|
||||||
|
|
||||||
|
This endpoint is called by the frontend after successfully confirming a SetupIntent
|
||||||
|
(with or without 3DS). It verifies the SetupIntent and creates the subscription.
|
||||||
|
|
||||||
|
Request body should contain:
|
||||||
|
- setup_intent_id: The SetupIntent ID that was confirmed
|
||||||
|
- customer_id: Stripe customer ID (from initial response)
|
||||||
|
- plan_id: Subscription plan ID
|
||||||
|
- payment_method_id: Payment method ID
|
||||||
|
- trial_period_days: Optional trial period
|
||||||
|
- user_id: User ID (for linking subscription to user)
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
setup_intent_id = request.get('setup_intent_id')
|
||||||
|
customer_id = request.get('customer_id')
|
||||||
|
plan_id = request.get('plan_id')
|
||||||
|
payment_method_id = request.get('payment_method_id')
|
||||||
|
trial_period_days = request.get('trial_period_days')
|
||||||
|
user_id = request.get('user_id')
|
||||||
|
billing_interval = request.get('billing_interval', 'monthly')
|
||||||
|
|
||||||
|
if not all([setup_intent_id, customer_id, plan_id, payment_method_id, user_id]):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="Missing required fields: setup_intent_id, customer_id, plan_id, payment_method_id, user_id"
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info("Completing subscription after SetupIntent confirmation",
|
||||||
|
setup_intent_id=setup_intent_id,
|
||||||
|
user_id=user_id,
|
||||||
|
plan_id=plan_id)
|
||||||
|
|
||||||
|
# Use orchestration service to complete subscription
|
||||||
|
orchestration_service = SubscriptionOrchestrationService(db)
|
||||||
|
|
||||||
|
result = await orchestration_service.complete_subscription_after_setup_intent(
|
||||||
|
setup_intent_id=setup_intent_id,
|
||||||
|
customer_id=customer_id,
|
||||||
|
plan_id=plan_id,
|
||||||
|
payment_method_id=payment_method_id,
|
||||||
|
trial_period_days=trial_period_days,
|
||||||
|
user_id=user_id,
|
||||||
|
billing_interval=billing_interval
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info("Subscription completed successfully after SetupIntent",
|
||||||
|
setup_intent_id=setup_intent_id,
|
||||||
|
subscription_id=result.get('subscription_id'),
|
||||||
|
user_id=user_id)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"message": "Subscription created successfully after SetupIntent confirmation",
|
||||||
|
**result
|
||||||
|
}
|
||||||
|
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("Failed to complete subscription after SetupIntent",
|
||||||
|
error=str(e),
|
||||||
|
setup_intent_id=request.get('setup_intent_id'))
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail=f"Failed to complete subscription: {str(e)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/api/v1/subscriptions/{tenant_id}/payment-method")
|
||||||
|
async def get_payment_method(
|
||||||
|
tenant_id: str = Path(..., description="Tenant ID"),
|
||||||
|
current_user: dict = Depends(get_current_user_dep),
|
||||||
|
db: AsyncSession = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Get the current payment method for a subscription
|
||||||
|
|
||||||
|
This endpoint retrieves the current payment method details from the payment provider
|
||||||
|
for display in the UI, including brand, last4 digits, and expiration date.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# Use SubscriptionOrchestrationService to get payment method
|
||||||
|
orchestration_service = SubscriptionOrchestrationService(db)
|
||||||
|
|
||||||
|
payment_method = await orchestration_service.get_payment_method(tenant_id)
|
||||||
|
|
||||||
|
if not payment_method:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="No payment method found for this subscription"
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info("payment_method_retrieved_via_api",
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
user_id=current_user.get("user_id"))
|
||||||
|
|
||||||
|
return payment_method
|
||||||
|
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except ValidationError as ve:
|
||||||
|
logger.error("get_payment_method_validation_failed",
|
||||||
|
error=str(ve), tenant_id=tenant_id)
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail=str(ve)
|
||||||
|
)
|
||||||
|
except DatabaseError as de:
|
||||||
|
logger.error("get_payment_method_failed",
|
||||||
|
error=str(de), tenant_id=tenant_id)
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail="Failed to retrieve payment method"
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("get_payment_method_unexpected_error",
|
||||||
|
error=str(e), tenant_id=tenant_id)
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail="An unexpected error occurred while retrieving payment method"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/api/v1/subscriptions/{tenant_id}/update-payment-method")
|
@router.post("/api/v1/subscriptions/{tenant_id}/update-payment-method")
|
||||||
async def update_payment_method(
|
async def update_payment_method(
|
||||||
tenant_id: str = Path(..., description="Tenant ID"),
|
tenant_id: str = Path(..., description="Tenant ID"),
|
||||||
@@ -1007,60 +1151,43 @@ async def update_payment_method(
|
|||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
Update the default payment method for a subscription
|
Update the default payment method for a subscription
|
||||||
|
|
||||||
This endpoint allows users to change their payment method through the UI.
|
This endpoint allows users to change their payment method through the UI.
|
||||||
It updates the default payment method in Stripe and returns the updated
|
It updates the default payment method with the payment provider and returns
|
||||||
payment method information.
|
the updated payment method information.
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
# Use SubscriptionService to get subscription and update payment method
|
# Use SubscriptionOrchestrationService to update payment method
|
||||||
subscription_service = SubscriptionService(db)
|
orchestration_service = SubscriptionOrchestrationService(db)
|
||||||
|
|
||||||
# Get current subscription
|
result = await orchestration_service.update_payment_method(
|
||||||
subscription = await subscription_service.get_subscription_by_tenant_id(tenant_id)
|
tenant_id,
|
||||||
if not subscription:
|
|
||||||
raise ValidationError(f"Subscription not found for tenant {tenant_id}")
|
|
||||||
|
|
||||||
if not subscription.stripe_customer_id:
|
|
||||||
raise ValidationError(f"Tenant {tenant_id} does not have a Stripe customer ID")
|
|
||||||
|
|
||||||
# Update payment method via PaymentService
|
|
||||||
payment_result = await subscription_service.payment_service.update_payment_method(
|
|
||||||
subscription.stripe_customer_id,
|
|
||||||
payment_method_id
|
payment_method_id
|
||||||
)
|
)
|
||||||
|
|
||||||
logger.info("Payment method updated successfully",
|
logger.info("Payment method updated successfully",
|
||||||
tenant_id=tenant_id,
|
tenant_id=tenant_id,
|
||||||
payment_method_id=payment_method_id,
|
payment_method_id=payment_method_id,
|
||||||
user_id=current_user.get("user_id"))
|
user_id=current_user.get("user_id"))
|
||||||
|
|
||||||
return {
|
return result
|
||||||
"success": True,
|
|
||||||
"message": "Payment method updated successfully",
|
|
||||||
"payment_method_id": payment_result.id,
|
|
||||||
"brand": getattr(payment_result, 'brand', 'unknown'),
|
|
||||||
"last4": getattr(payment_result, 'last4', '0000'),
|
|
||||||
"exp_month": getattr(payment_result, 'exp_month', None),
|
|
||||||
"exp_year": getattr(payment_result, 'exp_year', None)
|
|
||||||
}
|
|
||||||
|
|
||||||
except ValidationError as ve:
|
except ValidationError as ve:
|
||||||
logger.error("update_payment_method_validation_failed",
|
logger.error("update_payment_method_validation_failed",
|
||||||
error=str(ve), tenant_id=tenant_id)
|
error=str(ve), tenant_id=tenant_id)
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
detail=str(ve)
|
detail=str(ve)
|
||||||
)
|
)
|
||||||
except DatabaseError as de:
|
except DatabaseError as de:
|
||||||
logger.error("update_payment_method_failed",
|
logger.error("update_payment_method_failed",
|
||||||
error=str(de), tenant_id=tenant_id)
|
error=str(de), tenant_id=tenant_id)
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
detail="Failed to update payment method"
|
detail="Failed to update payment method"
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error("update_payment_method_unexpected_error",
|
logger.error("update_payment_method_unexpected_error",
|
||||||
error=str(e), tenant_id=tenant_id)
|
error=str(e), tenant_id=tenant_id)
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
@@ -1377,3 +1504,118 @@ async def redeem_coupon(
|
|||||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
detail="An unexpected error occurred while redeeming coupon"
|
detail="An unexpected error occurred while redeeming coupon"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# NEW ENDPOINTS FOR SECURE REGISTRATION ARCHITECTURE
|
||||||
|
|
||||||
|
class PaymentCustomerCreationRequest(BaseModel):
|
||||||
|
"""Request model for payment customer creation (pre-user-creation)"""
|
||||||
|
user_data: Dict[str, Any]
|
||||||
|
payment_method_id: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/payment-customers/create")
|
||||||
|
async def create_payment_customer_for_registration(
|
||||||
|
request: PaymentCustomerCreationRequest,
|
||||||
|
db: AsyncSession = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Create payment customer (supports pre-user-creation flow)
|
||||||
|
|
||||||
|
This endpoint creates a payment customer without requiring a user_id,
|
||||||
|
supporting the secure architecture where users are only created after
|
||||||
|
payment verification.
|
||||||
|
|
||||||
|
Uses SubscriptionOrchestrationService for proper workflow coordination.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
request: Payment customer creation request
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary with payment customer creation result
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
logger.info("Creating payment customer for registration (pre-user creation)",
|
||||||
|
email=request.user_data.get('email'),
|
||||||
|
payment_method_id=request.payment_method_id)
|
||||||
|
|
||||||
|
# Use orchestration service for proper workflow coordination
|
||||||
|
orchestration_service = SubscriptionOrchestrationService(db)
|
||||||
|
|
||||||
|
result = await orchestration_service.create_registration_payment_setup(
|
||||||
|
user_data=request.user_data,
|
||||||
|
plan_id=request.plan_id if hasattr(request, 'plan_id') else "professional",
|
||||||
|
payment_method_id=request.payment_method_id,
|
||||||
|
billing_interval="monthly", # Default for registration
|
||||||
|
coupon_code=request.user_data.get('coupon_code')
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info("Payment setup completed for registration",
|
||||||
|
email=request.user_data.get('email'),
|
||||||
|
requires_action=result.get('requires_action'),
|
||||||
|
setup_intent_id=result.get('setup_intent_id'))
|
||||||
|
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
**result # Include all orchestration service results
|
||||||
|
}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("Failed to create payment customer for registration",
|
||||||
|
email=request.user_data.get('email'),
|
||||||
|
error=str(e),
|
||||||
|
exc_info=True)
|
||||||
|
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail="Failed to create payment customer: " + str(e)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/setup-intents/{setup_intent_id}/verify")
|
||||||
|
async def verify_setup_intent(
|
||||||
|
setup_intent_id: str,
|
||||||
|
db: AsyncSession = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Verify SetupIntent status with payment provider
|
||||||
|
|
||||||
|
This endpoint checks if a SetupIntent has been successfully confirmed
|
||||||
|
(either automatically or via 3DS authentication).
|
||||||
|
|
||||||
|
Uses SubscriptionOrchestrationService for proper workflow coordination.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
setup_intent_id: SetupIntent ID to verify
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary with SetupIntent verification result
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
logger.info("Verifying SetupIntent status",
|
||||||
|
setup_intent_id=setup_intent_id)
|
||||||
|
|
||||||
|
# Use orchestration service for proper workflow coordination
|
||||||
|
orchestration_service = SubscriptionOrchestrationService(db)
|
||||||
|
|
||||||
|
# Verify SetupIntent using orchestration service
|
||||||
|
result = await orchestration_service.verify_setup_intent_for_registration(
|
||||||
|
setup_intent_id
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info("SetupIntent verification result",
|
||||||
|
setup_intent_id=setup_intent_id,
|
||||||
|
status=result.get('status'))
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("Failed to verify SetupIntent",
|
||||||
|
setup_intent_id=setup_intent_id,
|
||||||
|
error=str(e),
|
||||||
|
exc_info=True)
|
||||||
|
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail="Failed to verify SetupIntent: " + str(e)
|
||||||
|
)
|
||||||
|
|||||||
@@ -1138,7 +1138,7 @@ async def register_with_subscription(
|
|||||||
):
|
):
|
||||||
"""Process user registration with subscription creation"""
|
"""Process user registration with subscription creation"""
|
||||||
|
|
||||||
@router.post(route_builder.build_base_route("payment-customers/create", include_tenant_prefix=False))
|
@router.post("/api/v1/payment-customers/create")
|
||||||
async def create_payment_customer(
|
async def create_payment_customer(
|
||||||
user_data: Dict[str, Any],
|
user_data: Dict[str, Any],
|
||||||
payment_method_id: Optional[str] = Query(None, description="Optional payment method ID"),
|
payment_method_id: Optional[str] = Query(None, description="Optional payment method ID"),
|
||||||
@@ -1146,7 +1146,7 @@ async def create_payment_customer(
|
|||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
Create a payment customer in the payment provider
|
Create a payment customer in the payment provider
|
||||||
|
|
||||||
This endpoint is designed for service-to-service communication from auth service
|
This endpoint is designed for service-to-service communication from auth service
|
||||||
during user registration. It creates a payment customer that can be used later
|
during user registration. It creates a payment customer that can be used later
|
||||||
for subscription creation.
|
for subscription creation.
|
||||||
@@ -1241,7 +1241,7 @@ async def register_with_subscription(
|
|||||||
return {
|
return {
|
||||||
"success": True,
|
"success": True,
|
||||||
"message": "Registration and subscription created successfully",
|
"message": "Registration and subscription created successfully",
|
||||||
"data": result
|
**result
|
||||||
}
|
}
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error("Failed to register with subscription", error=str(e))
|
logger.error("Failed to register with subscription", error=str(e))
|
||||||
@@ -1291,7 +1291,7 @@ async def link_subscription_to_tenant(
|
|||||||
return {
|
return {
|
||||||
"success": True,
|
"success": True,
|
||||||
"message": "Subscription linked to tenant successfully",
|
"message": "Subscription linked to tenant successfully",
|
||||||
"data": result
|
**result
|
||||||
}
|
}
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|||||||
@@ -171,8 +171,10 @@ class Subscription(Base):
|
|||||||
trial_ends_at = Column(DateTime(timezone=True))
|
trial_ends_at = Column(DateTime(timezone=True))
|
||||||
cancelled_at = Column(DateTime(timezone=True), nullable=True)
|
cancelled_at = Column(DateTime(timezone=True), nullable=True)
|
||||||
cancellation_effective_date = Column(DateTime(timezone=True), nullable=True)
|
cancellation_effective_date = Column(DateTime(timezone=True), nullable=True)
|
||||||
stripe_subscription_id = Column(String(255), nullable=True)
|
|
||||||
stripe_customer_id = Column(String(255), nullable=True)
|
# Payment provider references (generic names for provider-agnostic design)
|
||||||
|
subscription_id = Column(String(255), nullable=True) # Payment provider subscription ID
|
||||||
|
customer_id = Column(String(255), nullable=True) # Payment provider customer ID
|
||||||
|
|
||||||
# Limits
|
# Limits
|
||||||
max_users = Column(Integer, default=5)
|
max_users = Column(Integer, default=5)
|
||||||
|
|||||||
@@ -120,12 +120,12 @@ class SubscriptionRepository(TenantBaseRepository):
|
|||||||
error=str(e))
|
error=str(e))
|
||||||
raise DatabaseError(f"Failed to get subscription: {str(e)}")
|
raise DatabaseError(f"Failed to get subscription: {str(e)}")
|
||||||
|
|
||||||
async def get_by_stripe_id(self, stripe_subscription_id: str) -> Optional[Subscription]:
|
async def get_by_provider_id(self, subscription_id: str) -> Optional[Subscription]:
|
||||||
"""Get subscription by Stripe subscription ID"""
|
"""Get subscription by payment provider subscription ID"""
|
||||||
try:
|
try:
|
||||||
subscriptions = await self.get_multi(
|
subscriptions = await self.get_multi(
|
||||||
filters={
|
filters={
|
||||||
"stripe_subscription_id": stripe_subscription_id
|
"subscription_id": subscription_id
|
||||||
},
|
},
|
||||||
limit=1,
|
limit=1,
|
||||||
order_by="created_at",
|
order_by="created_at",
|
||||||
@@ -133,8 +133,8 @@ class SubscriptionRepository(TenantBaseRepository):
|
|||||||
)
|
)
|
||||||
return subscriptions[0] if subscriptions else None
|
return subscriptions[0] if subscriptions else None
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error("Failed to get subscription by Stripe ID",
|
logger.error("Failed to get subscription by provider ID",
|
||||||
stripe_subscription_id=stripe_subscription_id,
|
subscription_id=subscription_id,
|
||||||
error=str(e))
|
error=str(e))
|
||||||
raise DatabaseError(f"Failed to get subscription: {str(e)}")
|
raise DatabaseError(f"Failed to get subscription: {str(e)}")
|
||||||
|
|
||||||
@@ -514,7 +514,7 @@ class SubscriptionRepository(TenantBaseRepository):
|
|||||||
"""Create a subscription not linked to any tenant (for registration flow)"""
|
"""Create a subscription not linked to any tenant (for registration flow)"""
|
||||||
try:
|
try:
|
||||||
# Validate required data for tenant-independent subscription
|
# Validate required data for tenant-independent subscription
|
||||||
required_fields = ["user_id", "plan", "stripe_subscription_id", "stripe_customer_id"]
|
required_fields = ["user_id", "plan", "subscription_id", "customer_id"]
|
||||||
validation_result = self._validate_tenant_data(subscription_data, required_fields)
|
validation_result = self._validate_tenant_data(subscription_data, required_fields)
|
||||||
|
|
||||||
if not validation_result["is_valid"]:
|
if not validation_result["is_valid"]:
|
||||||
|
|||||||
@@ -396,6 +396,10 @@ class TenantRepository(TenantBaseRepository):
|
|||||||
error=str(e))
|
error=str(e))
|
||||||
raise DatabaseError(f"Failed to get child tenants: {str(e)}")
|
raise DatabaseError(f"Failed to get child tenants: {str(e)}")
|
||||||
|
|
||||||
|
async def get(self, record_id: Any) -> Optional[Tenant]:
|
||||||
|
"""Get tenant by ID - alias for get_by_id for compatibility"""
|
||||||
|
return await self.get_by_id(record_id)
|
||||||
|
|
||||||
async def get_child_tenant_count(self, parent_tenant_id: str) -> int:
|
async def get_child_tenant_count(self, parent_tenant_id: str) -> int:
|
||||||
"""Get count of child tenants for a parent tenant"""
|
"""Get count of child tenants for a parent tenant"""
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -4,8 +4,9 @@ This service handles ONLY payment provider interactions (Stripe, etc.)
|
|||||||
NO business logic, NO database operations, NO orchestration
|
NO business logic, NO database operations, NO orchestration
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
import structlog
|
import structlog
|
||||||
from typing import Dict, Any, Optional, List
|
from typing import Dict, Any, Optional, List, Callable, Type
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
@@ -15,6 +16,51 @@ from shared.clients.stripe_client import StripeProvider
|
|||||||
logger = structlog.get_logger()
|
logger = structlog.get_logger()
|
||||||
|
|
||||||
|
|
||||||
|
async def retry_with_backoff(
|
||||||
|
func: Callable,
|
||||||
|
max_retries: int = 3,
|
||||||
|
base_delay: float = 1.0,
|
||||||
|
max_delay: float = 10.0,
|
||||||
|
exceptions: tuple = (Exception,)
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Generic retry function with exponential backoff
|
||||||
|
|
||||||
|
Args:
|
||||||
|
func: The async function to retry
|
||||||
|
max_retries: Maximum number of retry attempts
|
||||||
|
base_delay: Initial delay between retries in seconds
|
||||||
|
max_delay: Maximum delay between retries in seconds
|
||||||
|
exceptions: Tuple of exception types to retry on
|
||||||
|
"""
|
||||||
|
for attempt in range(max_retries + 1):
|
||||||
|
try:
|
||||||
|
return await func()
|
||||||
|
except exceptions as e:
|
||||||
|
if attempt == max_retries:
|
||||||
|
# Last attempt, re-raise the exception
|
||||||
|
raise e
|
||||||
|
|
||||||
|
# Calculate delay with exponential backoff and jitter
|
||||||
|
delay = min(base_delay * (2 ** attempt), max_delay)
|
||||||
|
jitter = delay * 0.1 # 10% jitter
|
||||||
|
actual_delay = delay + (jitter * (attempt % 2)) # Alternate between + and - jitter
|
||||||
|
|
||||||
|
logger.warning(
|
||||||
|
"Payment provider API call failed, retrying",
|
||||||
|
attempt=attempt + 1,
|
||||||
|
max_retries=max_retries,
|
||||||
|
delay=actual_delay,
|
||||||
|
error=str(e),
|
||||||
|
error_type=type(e).__name__
|
||||||
|
)
|
||||||
|
|
||||||
|
await asyncio.sleep(actual_delay)
|
||||||
|
|
||||||
|
# This should never be reached, but included for completeness
|
||||||
|
raise Exception("Max retries exceeded")
|
||||||
|
|
||||||
|
|
||||||
class PaymentService:
|
class PaymentService:
|
||||||
"""Service for handling payment provider interactions ONLY"""
|
"""Service for handling payment provider interactions ONLY"""
|
||||||
|
|
||||||
@@ -37,7 +83,13 @@ class PaymentService:
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return await self.payment_provider.create_customer(customer_data)
|
# Use retry logic for transient Stripe API failures
|
||||||
|
result = await retry_with_backoff(
|
||||||
|
lambda: self.payment_provider.create_customer(customer_data),
|
||||||
|
max_retries=3,
|
||||||
|
exceptions=(Exception,) # Catch all exceptions for payment provider calls
|
||||||
|
)
|
||||||
|
return result
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error("Failed to create customer in payment provider", error=str(e))
|
logger.error("Failed to create customer in payment provider", error=str(e))
|
||||||
raise e
|
raise e
|
||||||
@@ -49,7 +101,7 @@ class PaymentService:
|
|||||||
payment_method_id: str,
|
payment_method_id: str,
|
||||||
trial_period_days: Optional[int] = None,
|
trial_period_days: Optional[int] = None,
|
||||||
billing_interval: str = "monthly"
|
billing_interval: str = "monthly"
|
||||||
) -> Subscription:
|
) -> Dict[str, Any]:
|
||||||
"""
|
"""
|
||||||
Create a subscription in the payment provider
|
Create a subscription in the payment provider
|
||||||
|
|
||||||
@@ -61,18 +113,25 @@ class PaymentService:
|
|||||||
billing_interval: Billing interval (monthly/yearly)
|
billing_interval: Billing interval (monthly/yearly)
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Subscription object from payment provider
|
Dictionary containing subscription and authentication details
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
# Map the plan ID to the actual Stripe price ID
|
# Map the plan ID to the actual Stripe price ID
|
||||||
stripe_price_id = self._get_stripe_price_id(plan_id, billing_interval)
|
stripe_price_id = self._get_stripe_price_id(plan_id, billing_interval)
|
||||||
|
|
||||||
return await self.payment_provider.create_subscription(
|
# Use retry logic for transient Stripe API failures
|
||||||
customer_id,
|
result = await retry_with_backoff(
|
||||||
stripe_price_id,
|
lambda: self.payment_provider.create_subscription(
|
||||||
payment_method_id,
|
customer_id,
|
||||||
trial_period_days
|
stripe_price_id,
|
||||||
|
payment_method_id,
|
||||||
|
trial_period_days
|
||||||
|
),
|
||||||
|
max_retries=3,
|
||||||
|
exceptions=(Exception,) # Catch all exceptions for payment provider calls
|
||||||
)
|
)
|
||||||
|
|
||||||
|
return result
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error("Failed to create subscription in payment provider",
|
logger.error("Failed to create subscription in payment provider",
|
||||||
error=str(e),
|
error=str(e),
|
||||||
@@ -127,7 +186,7 @@ class PaymentService:
|
|||||||
logger.error("Failed to cancel subscription in payment provider", error=str(e))
|
logger.error("Failed to cancel subscription in payment provider", error=str(e))
|
||||||
raise e
|
raise e
|
||||||
|
|
||||||
async def update_payment_method(self, customer_id: str, payment_method_id: str) -> PaymentMethod:
|
async def update_payment_method(self, customer_id: str, payment_method_id: str) -> Dict[str, Any]:
|
||||||
"""
|
"""
|
||||||
Update the payment method for a customer
|
Update the payment method for a customer
|
||||||
|
|
||||||
@@ -136,10 +195,16 @@ class PaymentService:
|
|||||||
payment_method_id: New payment method ID
|
payment_method_id: New payment method ID
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
PaymentMethod object
|
Dictionary containing payment method and authentication details
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
return await self.payment_provider.update_payment_method(customer_id, payment_method_id)
|
# Use retry logic for transient Stripe API failures
|
||||||
|
result = await retry_with_backoff(
|
||||||
|
lambda: self.payment_provider.update_payment_method(customer_id, payment_method_id),
|
||||||
|
max_retries=3,
|
||||||
|
exceptions=(Exception,) # Catch all exceptions for payment provider calls
|
||||||
|
)
|
||||||
|
return result
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error("Failed to update payment method in payment provider", error=str(e))
|
logger.error("Failed to update payment method in payment provider", error=str(e))
|
||||||
raise e
|
raise e
|
||||||
@@ -155,11 +220,76 @@ class PaymentService:
|
|||||||
Subscription object
|
Subscription object
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
return await self.payment_provider.get_subscription(subscription_id)
|
# Use retry logic for transient Stripe API failures
|
||||||
|
result = await retry_with_backoff(
|
||||||
|
lambda: self.payment_provider.get_subscription(subscription_id),
|
||||||
|
max_retries=3,
|
||||||
|
exceptions=(Exception,) # Catch all exceptions for payment provider calls
|
||||||
|
)
|
||||||
|
return result
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error("Failed to get subscription from payment provider", error=str(e))
|
logger.error("Failed to get subscription from payment provider", error=str(e))
|
||||||
raise e
|
raise e
|
||||||
|
|
||||||
|
async def complete_subscription_after_setup_intent(
|
||||||
|
self,
|
||||||
|
setup_intent_id: str,
|
||||||
|
customer_id: str,
|
||||||
|
plan_id: str,
|
||||||
|
payment_method_id: str,
|
||||||
|
trial_period_days: Optional[int] = None
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Complete subscription creation after SetupIntent has been confirmed
|
||||||
|
|
||||||
|
This method is called after the frontend confirms a SetupIntent (with or without 3DS).
|
||||||
|
It verifies the SetupIntent and creates the subscription with the verified payment method.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
setup_intent_id: The SetupIntent ID that was confirmed
|
||||||
|
customer_id: Payment provider customer ID
|
||||||
|
plan_id: Subscription plan ID
|
||||||
|
payment_method_id: Payment method ID
|
||||||
|
trial_period_days: Optional trial period in days
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary containing subscription details
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
logger.info("Completing subscription after SetupIntent via payment service",
|
||||||
|
setup_intent_id=setup_intent_id,
|
||||||
|
customer_id=customer_id,
|
||||||
|
plan_id=plan_id)
|
||||||
|
|
||||||
|
# Map plan ID to Stripe price ID (default to monthly)
|
||||||
|
stripe_price_id = self._get_stripe_price_id(plan_id, "monthly")
|
||||||
|
|
||||||
|
# Use retry logic for transient Stripe API failures
|
||||||
|
result = await retry_with_backoff(
|
||||||
|
lambda: self.payment_provider.complete_subscription_after_setup_intent(
|
||||||
|
setup_intent_id,
|
||||||
|
customer_id,
|
||||||
|
stripe_price_id,
|
||||||
|
payment_method_id,
|
||||||
|
trial_period_days
|
||||||
|
),
|
||||||
|
max_retries=3,
|
||||||
|
exceptions=(Exception,)
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info("Subscription completed successfully after SetupIntent",
|
||||||
|
setup_intent_id=setup_intent_id,
|
||||||
|
subscription_id=result['subscription'].id if 'subscription' in result else None)
|
||||||
|
|
||||||
|
return result
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("Failed to complete subscription after SetupIntent in payment service",
|
||||||
|
error=str(e),
|
||||||
|
setup_intent_id=setup_intent_id,
|
||||||
|
customer_id=customer_id,
|
||||||
|
exc_info=True)
|
||||||
|
raise e
|
||||||
|
|
||||||
async def update_payment_subscription(
|
async def update_payment_subscription(
|
||||||
self,
|
self,
|
||||||
subscription_id: str,
|
subscription_id: str,
|
||||||
@@ -184,14 +314,20 @@ class PaymentService:
|
|||||||
Updated Subscription object
|
Updated Subscription object
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
return await self.payment_provider.update_subscription(
|
# Use retry logic for transient Stripe API failures
|
||||||
subscription_id,
|
result = await retry_with_backoff(
|
||||||
new_price_id,
|
lambda: self.payment_provider.update_subscription(
|
||||||
proration_behavior,
|
subscription_id,
|
||||||
billing_cycle_anchor,
|
new_price_id,
|
||||||
payment_behavior,
|
proration_behavior,
|
||||||
immediate_change
|
billing_cycle_anchor,
|
||||||
|
payment_behavior,
|
||||||
|
immediate_change
|
||||||
|
),
|
||||||
|
max_retries=3,
|
||||||
|
exceptions=(Exception,) # Catch all exceptions for payment provider calls
|
||||||
)
|
)
|
||||||
|
return result
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error("Failed to update subscription in payment provider", error=str(e))
|
logger.error("Failed to update subscription in payment provider", error=str(e))
|
||||||
raise e
|
raise e
|
||||||
@@ -214,11 +350,17 @@ class PaymentService:
|
|||||||
Dictionary with proration details
|
Dictionary with proration details
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
return await self.payment_provider.calculate_proration(
|
# Use retry logic for transient Stripe API failures
|
||||||
subscription_id,
|
result = await retry_with_backoff(
|
||||||
new_price_id,
|
lambda: self.payment_provider.calculate_proration(
|
||||||
proration_behavior
|
subscription_id,
|
||||||
|
new_price_id,
|
||||||
|
proration_behavior
|
||||||
|
),
|
||||||
|
max_retries=3,
|
||||||
|
exceptions=(Exception,) # Catch all exceptions for payment provider calls
|
||||||
)
|
)
|
||||||
|
return result
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error("Failed to calculate proration", error=str(e))
|
logger.error("Failed to calculate proration", error=str(e))
|
||||||
raise e
|
raise e
|
||||||
@@ -241,11 +383,17 @@ class PaymentService:
|
|||||||
Updated Subscription object
|
Updated Subscription object
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
return await self.payment_provider.change_billing_cycle(
|
# Use retry logic for transient Stripe API failures
|
||||||
subscription_id,
|
result = await retry_with_backoff(
|
||||||
new_billing_cycle,
|
lambda: self.payment_provider.change_billing_cycle(
|
||||||
proration_behavior
|
subscription_id,
|
||||||
|
new_billing_cycle,
|
||||||
|
proration_behavior
|
||||||
|
),
|
||||||
|
max_retries=3,
|
||||||
|
exceptions=(Exception,) # Catch all exceptions for payment provider calls
|
||||||
)
|
)
|
||||||
|
return result
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error("Failed to change billing cycle", error=str(e))
|
logger.error("Failed to change billing cycle", error=str(e))
|
||||||
raise e
|
raise e
|
||||||
@@ -264,8 +412,12 @@ class PaymentService:
|
|||||||
List of invoice dictionaries
|
List of invoice dictionaries
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
# Fetch invoices from payment provider
|
# Use retry logic for transient Stripe API failures
|
||||||
stripe_invoices = await self.payment_provider.get_invoices(customer_id)
|
stripe_invoices = await retry_with_backoff(
|
||||||
|
lambda: self.payment_provider.get_invoices(customer_id),
|
||||||
|
max_retries=3,
|
||||||
|
exceptions=(Exception,) # Catch all exceptions for payment provider calls
|
||||||
|
)
|
||||||
|
|
||||||
# Transform to response format
|
# Transform to response format
|
||||||
invoices = []
|
invoices = []
|
||||||
@@ -328,6 +480,28 @@ class PaymentService:
|
|||||||
logger.error("Failed to verify webhook signature", error=str(e))
|
logger.error("Failed to verify webhook signature", error=str(e))
|
||||||
raise e
|
raise e
|
||||||
|
|
||||||
|
async def get_customer_payment_method(self, customer_id: str) -> Optional[PaymentMethod]:
|
||||||
|
"""
|
||||||
|
Get the current payment method for a customer
|
||||||
|
|
||||||
|
Args:
|
||||||
|
customer_id: Payment provider customer ID
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
PaymentMethod object or None if no payment method exists
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# Use retry logic for transient Stripe API failures
|
||||||
|
result = await retry_with_backoff(
|
||||||
|
lambda: self.payment_provider.get_customer_payment_method(customer_id),
|
||||||
|
max_retries=3,
|
||||||
|
exceptions=(Exception,) # Catch all exceptions for payment provider calls
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("Failed to get customer payment method", error=str(e), customer_id=customer_id)
|
||||||
|
return None
|
||||||
|
|
||||||
async def process_registration_with_subscription(
|
async def process_registration_with_subscription(
|
||||||
self,
|
self,
|
||||||
user_data: Dict[str, Any],
|
user_data: Dict[str, Any],
|
||||||
@@ -338,20 +512,20 @@ class PaymentService:
|
|||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
"""
|
"""
|
||||||
Process user registration with subscription creation
|
Process user registration with subscription creation
|
||||||
|
|
||||||
This method handles the complete flow:
|
This method handles the complete flow:
|
||||||
1. Create payment customer (if not exists)
|
1. Create payment customer (if not exists)
|
||||||
2. Attach payment method to customer
|
2. Attach payment method to customer
|
||||||
3. Create subscription with coupon/trial
|
3. Create subscription with coupon/trial
|
||||||
4. Return subscription details
|
4. Return subscription details
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
user_data: User data including email, name, etc.
|
user_data: User data including email, name, etc.
|
||||||
plan_id: Subscription plan ID
|
plan_id: Subscription plan ID
|
||||||
payment_method_id: Payment method ID from frontend
|
payment_method_id: Payment method ID from frontend
|
||||||
coupon_code: Optional coupon code for discounts/trials
|
coupon_code: Optional coupon code for discounts/trials
|
||||||
billing_interval: Billing interval (monthly/yearly)
|
billing_interval: Billing interval (monthly/yearly)
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Dictionary with subscription and customer details
|
Dictionary with subscription and customer details
|
||||||
"""
|
"""
|
||||||
@@ -361,7 +535,7 @@ class PaymentService:
|
|||||||
logger.info("Payment customer created for registration",
|
logger.info("Payment customer created for registration",
|
||||||
customer_id=customer.id,
|
customer_id=customer.id,
|
||||||
email=user_data.get('email'))
|
email=user_data.get('email'))
|
||||||
|
|
||||||
# Step 2: Attach payment method to customer
|
# Step 2: Attach payment method to customer
|
||||||
if payment_method_id:
|
if payment_method_id:
|
||||||
try:
|
try:
|
||||||
@@ -375,7 +549,7 @@ class PaymentService:
|
|||||||
error=str(e))
|
error=str(e))
|
||||||
# Continue without attached payment method - user can add it later
|
# Continue without attached payment method - user can add it later
|
||||||
payment_method = None
|
payment_method = None
|
||||||
|
|
||||||
# Step 3: Determine trial period from coupon
|
# Step 3: Determine trial period from coupon
|
||||||
trial_period_days = None
|
trial_period_days = None
|
||||||
if coupon_code:
|
if coupon_code:
|
||||||
@@ -391,7 +565,7 @@ class PaymentService:
|
|||||||
# Other coupons might provide different trial periods
|
# Other coupons might provide different trial periods
|
||||||
# This would be configured in your coupon system
|
# This would be configured in your coupon system
|
||||||
trial_period_days = 30 # Default trial for other coupons
|
trial_period_days = 30 # Default trial for other coupons
|
||||||
|
|
||||||
# Step 4: Create subscription
|
# Step 4: Create subscription
|
||||||
subscription = await self.create_payment_subscription(
|
subscription = await self.create_payment_subscription(
|
||||||
customer.id,
|
customer.id,
|
||||||
@@ -400,13 +574,13 @@ class PaymentService:
|
|||||||
trial_period_days,
|
trial_period_days,
|
||||||
billing_interval
|
billing_interval
|
||||||
)
|
)
|
||||||
|
|
||||||
logger.info("Subscription created successfully during registration",
|
logger.info("Subscription created successfully during registration",
|
||||||
subscription_id=subscription.id,
|
subscription_id=subscription.id,
|
||||||
customer_id=customer.id,
|
customer_id=customer.id,
|
||||||
plan_id=plan_id,
|
plan_id=plan_id,
|
||||||
status=subscription.status)
|
status=subscription.status)
|
||||||
|
|
||||||
# Step 5: Return comprehensive result
|
# Step 5: Return comprehensive result
|
||||||
return {
|
return {
|
||||||
"success": True,
|
"success": True,
|
||||||
@@ -434,10 +608,132 @@ class PaymentService:
|
|||||||
"coupon_applied": coupon_code is not None,
|
"coupon_applied": coupon_code is not None,
|
||||||
"trial_active": trial_period_days is not None and trial_period_days > 0
|
"trial_active": trial_period_days is not None and trial_period_days > 0
|
||||||
}
|
}
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error("Failed to process registration with subscription",
|
logger.error("Failed to process registration with subscription",
|
||||||
error=str(e),
|
error=str(e),
|
||||||
plan_id=plan_id,
|
plan_id=plan_id,
|
||||||
customer_email=user_data.get('email'))
|
customer_email=user_data.get('email'))
|
||||||
raise e
|
raise e
|
||||||
|
|
||||||
|
async def create_payment_customer(
|
||||||
|
self,
|
||||||
|
user_data: Dict[str, Any],
|
||||||
|
payment_method_id: Optional[str] = None
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Create payment customer (supports pre-user-creation flow)
|
||||||
|
|
||||||
|
This method creates a payment customer without requiring a user_id,
|
||||||
|
supporting the secure architecture where users are only created after
|
||||||
|
payment verification.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user_data: User data (email, full_name, etc.)
|
||||||
|
payment_method_id: Optional payment method ID
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary with payment customer creation result
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# Create customer without user_id (for pre-user-creation flow)
|
||||||
|
customer_data = {
|
||||||
|
'email': user_data.get('email'),
|
||||||
|
'name': user_data.get('full_name'),
|
||||||
|
'metadata': {
|
||||||
|
'registration_flow': 'pre_user_creation',
|
||||||
|
'timestamp': datetime.now(timezone.utc).isoformat()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Create customer in payment provider
|
||||||
|
customer = await retry_with_backoff(
|
||||||
|
lambda: self.payment_provider.create_customer(customer_data),
|
||||||
|
max_retries=3,
|
||||||
|
exceptions=(Exception,)
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info("Payment customer created for registration (pre-user creation)",
|
||||||
|
customer_id=customer.id,
|
||||||
|
email=user_data.get('email'))
|
||||||
|
|
||||||
|
# Optionally attach payment method if provided
|
||||||
|
payment_method = None
|
||||||
|
if payment_method_id:
|
||||||
|
try:
|
||||||
|
payment_method = await self.update_payment_method(
|
||||||
|
customer.id,
|
||||||
|
payment_method_id
|
||||||
|
)
|
||||||
|
logger.info("Payment method attached to customer (pre-user creation)",
|
||||||
|
customer_id=customer.id,
|
||||||
|
payment_method_id=payment_method.id)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("Failed to attach payment method during pre-user creation",
|
||||||
|
customer_id=customer.id,
|
||||||
|
error=str(e))
|
||||||
|
# Continue without payment method - can be added later
|
||||||
|
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"payment_customer_id": customer.id,
|
||||||
|
"customer_id": customer.id,
|
||||||
|
"email": user_data.get('email'),
|
||||||
|
"payment_method_id": payment_method.id if payment_method else None,
|
||||||
|
"payment_method_type": payment_method.type if payment_method else None,
|
||||||
|
"payment_method_last4": payment_method.last4 if payment_method else None
|
||||||
|
}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("Failed to create payment customer for registration",
|
||||||
|
email=user_data.get('email'),
|
||||||
|
error=str(e),
|
||||||
|
exc_info=True)
|
||||||
|
raise
|
||||||
|
|
||||||
|
async def verify_setup_intent(
|
||||||
|
self,
|
||||||
|
setup_intent_id: str
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Verify SetupIntent status with payment provider
|
||||||
|
|
||||||
|
This method checks if a SetupIntent has been successfully confirmed
|
||||||
|
(either automatically or via 3DS authentication).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
setup_intent_id: SetupIntent ID to verify
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary with SetupIntent verification result
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# Retrieve SetupIntent from payment provider
|
||||||
|
setup_intent = await retry_with_backoff(
|
||||||
|
lambda: self.payment_provider.get_setup_intent(setup_intent_id),
|
||||||
|
max_retries=3,
|
||||||
|
exceptions=(Exception,)
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info("SetupIntent verification result",
|
||||||
|
setup_intent_id=setup_intent_id,
|
||||||
|
status=setup_intent.status)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"setup_intent_id": setup_intent.id,
|
||||||
|
"status": setup_intent.status,
|
||||||
|
"customer_id": setup_intent.customer,
|
||||||
|
"payment_method_id": setup_intent.payment_method,
|
||||||
|
"created": setup_intent.created,
|
||||||
|
"last_setup_error": setup_intent.last_setup_error,
|
||||||
|
"next_action": setup_intent.next_action,
|
||||||
|
"usage": setup_intent.usage
|
||||||
|
}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("Failed to verify SetupIntent",
|
||||||
|
setup_intent_id=setup_intent_id,
|
||||||
|
error=str(e),
|
||||||
|
exc_info=True)
|
||||||
|
raise
|
||||||
|
|||||||
@@ -121,7 +121,7 @@ class SubscriptionOrchestrationService:
|
|||||||
# Step 4: Create local subscription record
|
# Step 4: Create local subscription record
|
||||||
logger.info("Creating local subscription record",
|
logger.info("Creating local subscription record",
|
||||||
tenant_id=tenant_id,
|
tenant_id=tenant_id,
|
||||||
stripe_subscription_id=stripe_subscription.id)
|
subscription_id=stripe_subscription.id)
|
||||||
|
|
||||||
subscription_record = await self.subscription_service.create_subscription_record(
|
subscription_record = await self.subscription_service.create_subscription_record(
|
||||||
tenant_id,
|
tenant_id,
|
||||||
@@ -141,7 +141,7 @@ class SubscriptionOrchestrationService:
|
|||||||
tenant_id=tenant_id)
|
tenant_id=tenant_id)
|
||||||
|
|
||||||
tenant_update_data = {
|
tenant_update_data = {
|
||||||
'stripe_customer_id': customer.id,
|
'customer_id': customer.id,
|
||||||
'subscription_status': stripe_subscription.status,
|
'subscription_status': stripe_subscription.status,
|
||||||
'subscription_plan': plan_id,
|
'subscription_plan': plan_id,
|
||||||
'subscription_tier': plan_id,
|
'subscription_tier': plan_id,
|
||||||
@@ -265,13 +265,13 @@ class SubscriptionOrchestrationService:
|
|||||||
coupon_code=coupon_code,
|
coupon_code=coupon_code,
|
||||||
error=error)
|
error=error)
|
||||||
|
|
||||||
# Step 3: Create subscription in payment provider
|
# Step 3: Create subscription in payment provider (or get SetupIntent for 3DS)
|
||||||
logger.info("Creating subscription in payment provider",
|
logger.info("Creating subscription in payment provider",
|
||||||
customer_id=customer.id,
|
customer_id=customer.id,
|
||||||
plan_id=plan_id,
|
plan_id=plan_id,
|
||||||
trial_period_days=trial_period_days)
|
trial_period_days=trial_period_days)
|
||||||
|
|
||||||
stripe_subscription = await self.payment_service.create_payment_subscription(
|
subscription_result = await self.payment_service.create_payment_subscription(
|
||||||
customer.id,
|
customer.id,
|
||||||
plan_id,
|
plan_id,
|
||||||
payment_method_id,
|
payment_method_id,
|
||||||
@@ -279,6 +279,35 @@ class SubscriptionOrchestrationService:
|
|||||||
billing_interval
|
billing_interval
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Check if result requires 3DS authentication (SetupIntent confirmation)
|
||||||
|
if isinstance(subscription_result, dict) and subscription_result.get('requires_action'):
|
||||||
|
logger.info("Subscription creation requires SetupIntent confirmation",
|
||||||
|
customer_id=customer.id,
|
||||||
|
action_type=subscription_result.get('action_type'),
|
||||||
|
setup_intent_id=subscription_result.get('setup_intent_id'))
|
||||||
|
|
||||||
|
# Return the SetupIntent data for frontend to handle 3DS
|
||||||
|
return {
|
||||||
|
"requires_action": True,
|
||||||
|
"action_type": subscription_result.get('action_type'),
|
||||||
|
"client_secret": subscription_result.get('client_secret'),
|
||||||
|
"setup_intent_id": subscription_result.get('setup_intent_id'),
|
||||||
|
"customer_id": customer.id,
|
||||||
|
"payment_method_id": payment_method_id,
|
||||||
|
"plan_id": plan_id,
|
||||||
|
"trial_period_days": trial_period_days,
|
||||||
|
"billing_interval": billing_interval,
|
||||||
|
"message": subscription_result.get('message'),
|
||||||
|
"user_id": user_data.get('user_id')
|
||||||
|
}
|
||||||
|
|
||||||
|
# Extract subscription object from result
|
||||||
|
# Result can be either a dict with 'subscription' key or the subscription object directly
|
||||||
|
if isinstance(subscription_result, dict) and 'subscription' in subscription_result:
|
||||||
|
stripe_subscription = subscription_result['subscription']
|
||||||
|
else:
|
||||||
|
stripe_subscription = subscription_result
|
||||||
|
|
||||||
logger.info("Subscription created in payment provider",
|
logger.info("Subscription created in payment provider",
|
||||||
subscription_id=stripe_subscription.id,
|
subscription_id=stripe_subscription.id,
|
||||||
status=stripe_subscription.status)
|
status=stripe_subscription.status)
|
||||||
@@ -286,7 +315,7 @@ class SubscriptionOrchestrationService:
|
|||||||
# Step 4: Create local subscription record WITHOUT tenant_id
|
# Step 4: Create local subscription record WITHOUT tenant_id
|
||||||
logger.info("Creating tenant-independent subscription record",
|
logger.info("Creating tenant-independent subscription record",
|
||||||
user_id=user_data.get('user_id'),
|
user_id=user_data.get('user_id'),
|
||||||
stripe_subscription_id=stripe_subscription.id)
|
subscription_id=stripe_subscription.id)
|
||||||
|
|
||||||
subscription_record = await self.subscription_service.create_tenant_independent_subscription_record(
|
subscription_record = await self.subscription_service.create_tenant_independent_subscription_record(
|
||||||
stripe_subscription.id,
|
stripe_subscription.id,
|
||||||
@@ -345,6 +374,100 @@ class SubscriptionOrchestrationService:
|
|||||||
error=str(e), user_id=user_data.get('user_id'))
|
error=str(e), user_id=user_data.get('user_id'))
|
||||||
raise DatabaseError(f"Failed to create tenant-independent subscription: {str(e)}")
|
raise DatabaseError(f"Failed to create tenant-independent subscription: {str(e)}")
|
||||||
|
|
||||||
|
async def complete_subscription_after_setup_intent(
|
||||||
|
self,
|
||||||
|
setup_intent_id: str,
|
||||||
|
customer_id: str,
|
||||||
|
plan_id: str,
|
||||||
|
payment_method_id: str,
|
||||||
|
trial_period_days: Optional[int],
|
||||||
|
user_id: str,
|
||||||
|
billing_interval: str = "monthly"
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Complete subscription creation after SetupIntent has been confirmed
|
||||||
|
|
||||||
|
This method is called after the frontend successfully confirms a SetupIntent
|
||||||
|
(with or without 3DS). It creates the subscription with the verified payment method
|
||||||
|
and creates a database record.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
setup_intent_id: The confirmed SetupIntent ID
|
||||||
|
customer_id: Stripe customer ID
|
||||||
|
plan_id: Subscription plan ID
|
||||||
|
payment_method_id: Verified payment method ID
|
||||||
|
trial_period_days: Optional trial period
|
||||||
|
user_id: User ID for linking
|
||||||
|
billing_interval: Billing interval
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary with subscription details
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
logger.info("Completing subscription after SetupIntent confirmation",
|
||||||
|
setup_intent_id=setup_intent_id,
|
||||||
|
user_id=user_id,
|
||||||
|
plan_id=plan_id)
|
||||||
|
|
||||||
|
# Call payment service to complete subscription creation
|
||||||
|
result = await self.payment_service.complete_subscription_after_setup_intent(
|
||||||
|
setup_intent_id,
|
||||||
|
customer_id,
|
||||||
|
plan_id,
|
||||||
|
payment_method_id,
|
||||||
|
trial_period_days
|
||||||
|
)
|
||||||
|
|
||||||
|
stripe_subscription = result['subscription']
|
||||||
|
|
||||||
|
logger.info("Subscription created in payment provider after SetupIntent",
|
||||||
|
subscription_id=stripe_subscription.id,
|
||||||
|
status=stripe_subscription.status)
|
||||||
|
|
||||||
|
# Create local subscription record WITHOUT tenant_id (tenant-independent)
|
||||||
|
subscription_record = await self.subscription_service.create_tenant_independent_subscription_record(
|
||||||
|
stripe_subscription.id,
|
||||||
|
customer_id,
|
||||||
|
plan_id,
|
||||||
|
stripe_subscription.status,
|
||||||
|
trial_period_days,
|
||||||
|
billing_interval,
|
||||||
|
user_id
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info("Tenant-independent subscription record created after SetupIntent",
|
||||||
|
subscription_id=stripe_subscription.id,
|
||||||
|
user_id=user_id)
|
||||||
|
|
||||||
|
# Convert current_period_end to ISO format
|
||||||
|
current_period_end = stripe_subscription.current_period_end
|
||||||
|
if isinstance(current_period_end, int):
|
||||||
|
current_period_end = datetime.fromtimestamp(current_period_end, tz=timezone.utc).isoformat()
|
||||||
|
elif hasattr(current_period_end, 'isoformat'):
|
||||||
|
current_period_end = current_period_end.isoformat()
|
||||||
|
else:
|
||||||
|
current_period_end = str(current_period_end)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"customer_id": customer_id,
|
||||||
|
"subscription_id": stripe_subscription.id,
|
||||||
|
"status": stripe_subscription.status,
|
||||||
|
"plan": plan_id,
|
||||||
|
"billing_cycle": billing_interval,
|
||||||
|
"trial_period_days": trial_period_days,
|
||||||
|
"current_period_end": current_period_end,
|
||||||
|
"user_id": user_id,
|
||||||
|
"setup_intent_id": setup_intent_id
|
||||||
|
}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("Failed to complete subscription after SetupIntent",
|
||||||
|
error=str(e),
|
||||||
|
setup_intent_id=setup_intent_id,
|
||||||
|
user_id=user_id)
|
||||||
|
raise DatabaseError(f"Failed to complete subscription: {str(e)}")
|
||||||
|
|
||||||
async def orchestrate_subscription_cancellation(
|
async def orchestrate_subscription_cancellation(
|
||||||
self,
|
self,
|
||||||
tenant_id: str,
|
tenant_id: str,
|
||||||
@@ -383,7 +506,7 @@ class SubscriptionOrchestrationService:
|
|||||||
)
|
)
|
||||||
|
|
||||||
logger.info("Subscription cancelled in payment provider",
|
logger.info("Subscription cancelled in payment provider",
|
||||||
stripe_subscription_id=stripe_subscription.id,
|
subscription_id=stripe_subscription.id,
|
||||||
stripe_status=stripe_subscription.status)
|
stripe_status=stripe_subscription.status)
|
||||||
|
|
||||||
# Step 4: Sync status back to database
|
# Step 4: Sync status back to database
|
||||||
@@ -536,7 +659,7 @@ class SubscriptionOrchestrationService:
|
|||||||
)
|
)
|
||||||
|
|
||||||
logger.info("Plan updated in payment provider",
|
logger.info("Plan updated in payment provider",
|
||||||
stripe_subscription_id=updated_stripe_subscription.id,
|
subscription_id=updated_stripe_subscription.id,
|
||||||
new_status=updated_stripe_subscription.status)
|
new_status=updated_stripe_subscription.status)
|
||||||
|
|
||||||
# Step 5: Update local subscription record
|
# Step 5: Update local subscription record
|
||||||
@@ -622,7 +745,7 @@ class SubscriptionOrchestrationService:
|
|||||||
)
|
)
|
||||||
|
|
||||||
logger.info("Billing cycle changed in payment provider",
|
logger.info("Billing cycle changed in payment provider",
|
||||||
stripe_subscription_id=updated_stripe_subscription.id,
|
subscription_id=updated_stripe_subscription.id,
|
||||||
new_billing_cycle=new_billing_cycle)
|
new_billing_cycle=new_billing_cycle)
|
||||||
|
|
||||||
# Step 3: Get proration details (if available)
|
# Step 3: Get proration details (if available)
|
||||||
@@ -771,6 +894,26 @@ class SubscriptionOrchestrationService:
|
|||||||
await self._handle_subscription_resumed(event_data)
|
await self._handle_subscription_resumed(event_data)
|
||||||
result["actions_taken"].append("subscription_resumed")
|
result["actions_taken"].append("subscription_resumed")
|
||||||
|
|
||||||
|
elif event_type == 'payment_intent.succeeded':
|
||||||
|
await self._handle_payment_intent_succeeded(event_data)
|
||||||
|
result["actions_taken"].append("payment_intent_succeeded")
|
||||||
|
|
||||||
|
elif event_type == 'payment_intent.payment_failed':
|
||||||
|
await self._handle_payment_intent_failed(event_data)
|
||||||
|
result["actions_taken"].append("payment_intent_failed")
|
||||||
|
|
||||||
|
elif event_type == 'payment_intent.requires_action':
|
||||||
|
await self._handle_payment_intent_requires_action(event_data)
|
||||||
|
result["actions_taken"].append("payment_intent_requires_action")
|
||||||
|
|
||||||
|
elif event_type == 'setup_intent.succeeded':
|
||||||
|
await self._handle_setup_intent_succeeded(event_data)
|
||||||
|
result["actions_taken"].append("setup_intent_succeeded")
|
||||||
|
|
||||||
|
elif event_type == 'setup_intent.requires_action':
|
||||||
|
await self._handle_setup_intent_requires_action(event_data)
|
||||||
|
result["actions_taken"].append("setup_intent_requires_action")
|
||||||
|
|
||||||
else:
|
else:
|
||||||
logger.info("Unhandled webhook event type", event_type=event_type)
|
logger.info("Unhandled webhook event type", event_type=event_type)
|
||||||
result["processed"] = False
|
result["processed"] = False
|
||||||
@@ -800,7 +943,7 @@ class SubscriptionOrchestrationService:
|
|||||||
status=status)
|
status=status)
|
||||||
|
|
||||||
# Find tenant by customer ID
|
# Find tenant by customer ID
|
||||||
tenant = await self.tenant_service.get_tenant_by_stripe_customer_id(customer_id)
|
tenant = await self.tenant_service.get_tenant_by_customer_id(customer_id)
|
||||||
|
|
||||||
if tenant:
|
if tenant:
|
||||||
# Update subscription status
|
# Update subscription status
|
||||||
@@ -896,7 +1039,7 @@ class SubscriptionOrchestrationService:
|
|||||||
customer_id=customer_id)
|
customer_id=customer_id)
|
||||||
|
|
||||||
# Find tenant and update payment status
|
# Find tenant and update payment status
|
||||||
tenant = await self.tenant_service.get_tenant_by_stripe_customer_id(customer_id)
|
tenant = await self.tenant_service.get_tenant_by_customer_id(customer_id)
|
||||||
|
|
||||||
if tenant:
|
if tenant:
|
||||||
tenant_update_data = {
|
tenant_update_data = {
|
||||||
@@ -924,7 +1067,7 @@ class SubscriptionOrchestrationService:
|
|||||||
customer_id=customer_id)
|
customer_id=customer_id)
|
||||||
|
|
||||||
# Find tenant and update payment status
|
# Find tenant and update payment status
|
||||||
tenant = await self.tenant_service.get_tenant_by_stripe_customer_id(customer_id)
|
tenant = await self.tenant_service.get_tenant_by_customer_id(customer_id)
|
||||||
|
|
||||||
if tenant:
|
if tenant:
|
||||||
tenant_update_data = {
|
tenant_update_data = {
|
||||||
@@ -951,7 +1094,7 @@ class SubscriptionOrchestrationService:
|
|||||||
customer_id=customer_id,
|
customer_id=customer_id,
|
||||||
trial_end=trial_end)
|
trial_end=trial_end)
|
||||||
|
|
||||||
tenant = await self.tenant_service.get_tenant_by_stripe_customer_id(customer_id)
|
tenant = await self.tenant_service.get_tenant_by_customer_id(customer_id)
|
||||||
|
|
||||||
if tenant:
|
if tenant:
|
||||||
tenant_update_data = {
|
tenant_update_data = {
|
||||||
@@ -978,7 +1121,7 @@ class SubscriptionOrchestrationService:
|
|||||||
customer_id=customer_id,
|
customer_id=customer_id,
|
||||||
subscription_id=subscription_id)
|
subscription_id=subscription_id)
|
||||||
|
|
||||||
tenant = await self.tenant_service.get_tenant_by_stripe_customer_id(customer_id)
|
tenant = await self.tenant_service.get_tenant_by_customer_id(customer_id)
|
||||||
|
|
||||||
if tenant:
|
if tenant:
|
||||||
tenant_update_data = {
|
tenant_update_data = {
|
||||||
@@ -1004,7 +1147,7 @@ class SubscriptionOrchestrationService:
|
|||||||
subscription_id=subscription_id,
|
subscription_id=subscription_id,
|
||||||
customer_id=customer_id)
|
customer_id=customer_id)
|
||||||
|
|
||||||
tenant = await self.tenant_service.get_tenant_by_stripe_customer_id(customer_id)
|
tenant = await self.tenant_service.get_tenant_by_customer_id(customer_id)
|
||||||
|
|
||||||
if tenant:
|
if tenant:
|
||||||
await self.subscription_service.update_subscription_status(
|
await self.subscription_service.update_subscription_status(
|
||||||
@@ -1038,7 +1181,7 @@ class SubscriptionOrchestrationService:
|
|||||||
subscription_id=subscription_id,
|
subscription_id=subscription_id,
|
||||||
customer_id=customer_id)
|
customer_id=customer_id)
|
||||||
|
|
||||||
tenant = await self.tenant_service.get_tenant_by_stripe_customer_id(customer_id)
|
tenant = await self.tenant_service.get_tenant_by_customer_id(customer_id)
|
||||||
|
|
||||||
if tenant:
|
if tenant:
|
||||||
await self.subscription_service.update_subscription_status(
|
await self.subscription_service.update_subscription_status(
|
||||||
@@ -1062,6 +1205,155 @@ class SubscriptionOrchestrationService:
|
|||||||
tenant_id=str(tenant.id),
|
tenant_id=str(tenant.id),
|
||||||
subscription_id=subscription_id)
|
subscription_id=subscription_id)
|
||||||
|
|
||||||
|
async def _handle_payment_intent_succeeded(self, event_data: Dict[str, Any]):
|
||||||
|
"""Handle payment intent succeeded event (including 3DS authenticated payments)"""
|
||||||
|
payment_intent_id = event_data['id']
|
||||||
|
customer_id = event_data.get('customer')
|
||||||
|
amount = event_data.get('amount', 0) / 100.0
|
||||||
|
currency = event_data.get('currency', 'eur').upper()
|
||||||
|
|
||||||
|
logger.info("Handling payment intent succeeded event",
|
||||||
|
payment_intent_id=payment_intent_id,
|
||||||
|
customer_id=customer_id,
|
||||||
|
amount=amount)
|
||||||
|
|
||||||
|
if customer_id:
|
||||||
|
tenant = await self.tenant_service.get_tenant_by_customer_id(customer_id)
|
||||||
|
|
||||||
|
if tenant:
|
||||||
|
tenant_update_data = {
|
||||||
|
'payment_action_required': False,
|
||||||
|
'last_successful_payment_at': datetime.now(timezone.utc)
|
||||||
|
}
|
||||||
|
|
||||||
|
await self.tenant_service.update_tenant_subscription_info(
|
||||||
|
str(tenant.id), tenant_update_data
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info("Payment intent succeeded event handled",
|
||||||
|
tenant_id=str(tenant.id),
|
||||||
|
payment_intent_id=payment_intent_id)
|
||||||
|
|
||||||
|
async def _handle_payment_intent_failed(self, event_data: Dict[str, Any]):
|
||||||
|
"""Handle payment intent failed event (including 3DS authentication failures)"""
|
||||||
|
payment_intent_id = event_data['id']
|
||||||
|
customer_id = event_data.get('customer')
|
||||||
|
last_payment_error = event_data.get('last_payment_error', {})
|
||||||
|
error_message = last_payment_error.get('message', 'Payment failed')
|
||||||
|
|
||||||
|
logger.warning("Handling payment intent failed event",
|
||||||
|
payment_intent_id=payment_intent_id,
|
||||||
|
customer_id=customer_id,
|
||||||
|
error_message=error_message)
|
||||||
|
|
||||||
|
if customer_id:
|
||||||
|
tenant = await self.tenant_service.get_tenant_by_customer_id(customer_id)
|
||||||
|
|
||||||
|
if tenant:
|
||||||
|
tenant_update_data = {
|
||||||
|
'payment_action_required': False,
|
||||||
|
'last_payment_failure_at': datetime.now(timezone.utc),
|
||||||
|
'last_payment_error': error_message
|
||||||
|
}
|
||||||
|
|
||||||
|
await self.tenant_service.update_tenant_subscription_info(
|
||||||
|
str(tenant.id), tenant_update_data
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info("Payment intent failed event handled",
|
||||||
|
tenant_id=str(tenant.id),
|
||||||
|
payment_intent_id=payment_intent_id)
|
||||||
|
|
||||||
|
async def _handle_payment_intent_requires_action(self, event_data: Dict[str, Any]):
|
||||||
|
"""Handle payment intent requires action event (3DS authentication needed)"""
|
||||||
|
payment_intent_id = event_data['id']
|
||||||
|
customer_id = event_data.get('customer')
|
||||||
|
next_action = event_data.get('next_action', {})
|
||||||
|
action_type = next_action.get('type', 'unknown')
|
||||||
|
|
||||||
|
logger.info("Handling payment intent requires action event",
|
||||||
|
payment_intent_id=payment_intent_id,
|
||||||
|
customer_id=customer_id,
|
||||||
|
action_type=action_type)
|
||||||
|
|
||||||
|
if customer_id:
|
||||||
|
tenant = await self.tenant_service.get_tenant_by_customer_id(customer_id)
|
||||||
|
|
||||||
|
if tenant:
|
||||||
|
tenant_update_data = {
|
||||||
|
'payment_action_required': True,
|
||||||
|
'payment_action_type': action_type,
|
||||||
|
'last_payment_action_required_at': datetime.now(timezone.utc)
|
||||||
|
}
|
||||||
|
|
||||||
|
await self.tenant_service.update_tenant_subscription_info(
|
||||||
|
str(tenant.id), tenant_update_data
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info("Payment intent requires action event handled",
|
||||||
|
tenant_id=str(tenant.id),
|
||||||
|
payment_intent_id=payment_intent_id,
|
||||||
|
action_type=action_type)
|
||||||
|
|
||||||
|
async def _handle_setup_intent_succeeded(self, event_data: Dict[str, Any]):
|
||||||
|
"""Handle setup intent succeeded event (3DS authentication completed)"""
|
||||||
|
setup_intent_id = event_data['id']
|
||||||
|
customer_id = event_data.get('customer')
|
||||||
|
|
||||||
|
logger.info("Handling setup intent succeeded event (3DS authentication completed)",
|
||||||
|
setup_intent_id=setup_intent_id,
|
||||||
|
customer_id=customer_id)
|
||||||
|
|
||||||
|
if customer_id:
|
||||||
|
tenant = await self.tenant_service.get_tenant_by_customer_id(customer_id)
|
||||||
|
|
||||||
|
if tenant:
|
||||||
|
tenant_update_data = {
|
||||||
|
'threeds_authentication_completed': True,
|
||||||
|
'threeds_authentication_completed_at': datetime.now(timezone.utc),
|
||||||
|
'last_threeds_setup_intent_id': setup_intent_id
|
||||||
|
}
|
||||||
|
|
||||||
|
await self.tenant_service.update_tenant_subscription_info(
|
||||||
|
str(tenant.id), tenant_update_data
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info("Setup intent succeeded event handled (3DS authentication completed)",
|
||||||
|
tenant_id=str(tenant.id),
|
||||||
|
setup_intent_id=setup_intent_id)
|
||||||
|
|
||||||
|
async def _handle_setup_intent_requires_action(self, event_data: Dict[str, Any]):
|
||||||
|
"""Handle setup intent requires action event (3DS authentication needed)"""
|
||||||
|
setup_intent_id = event_data['id']
|
||||||
|
customer_id = event_data.get('customer')
|
||||||
|
next_action = event_data.get('next_action', {})
|
||||||
|
action_type = next_action.get('type', 'unknown')
|
||||||
|
|
||||||
|
logger.info("Handling setup intent requires action event (3DS authentication needed)",
|
||||||
|
setup_intent_id=setup_intent_id,
|
||||||
|
customer_id=customer_id,
|
||||||
|
action_type=action_type)
|
||||||
|
|
||||||
|
if customer_id:
|
||||||
|
tenant = await self.tenant_service.get_tenant_by_customer_id(customer_id)
|
||||||
|
|
||||||
|
if tenant:
|
||||||
|
tenant_update_data = {
|
||||||
|
'threeds_authentication_required': True,
|
||||||
|
'threeds_authentication_required_at': datetime.now(timezone.utc),
|
||||||
|
'last_threeds_setup_intent_id': setup_intent_id,
|
||||||
|
'threeds_action_type': action_type
|
||||||
|
}
|
||||||
|
|
||||||
|
await self.tenant_service.update_tenant_subscription_info(
|
||||||
|
str(tenant.id), tenant_update_data
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info("Setup intent requires action event handled (3DS authentication needed)",
|
||||||
|
tenant_id=str(tenant.id),
|
||||||
|
setup_intent_id=setup_intent_id,
|
||||||
|
action_type=action_type)
|
||||||
|
|
||||||
async def orchestrate_subscription_creation_with_default_payment(
|
async def orchestrate_subscription_creation_with_default_payment(
|
||||||
self,
|
self,
|
||||||
tenant_id: str,
|
tenant_id: str,
|
||||||
@@ -1165,3 +1457,310 @@ class SubscriptionOrchestrationService:
|
|||||||
error=str(e))
|
error=str(e))
|
||||||
# Don't fail the subscription creation if we can't get the default payment method
|
# Don't fail the subscription creation if we can't get the default payment method
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
async def get_payment_method(self, tenant_id: str) -> Optional[Dict[str, Any]]:
|
||||||
|
"""
|
||||||
|
Get the current payment method for a tenant's subscription
|
||||||
|
|
||||||
|
This is an orchestration method that coordinates between:
|
||||||
|
1. SubscriptionService (to get subscription data)
|
||||||
|
2. PaymentService (to get payment method from provider)
|
||||||
|
|
||||||
|
Args:
|
||||||
|
tenant_id: Tenant ID
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary with payment method details or None
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# Get subscription from database
|
||||||
|
subscription = await self.subscription_service.get_subscription_by_tenant_id(tenant_id)
|
||||||
|
|
||||||
|
if not subscription:
|
||||||
|
logger.warning("get_payment_method_no_subscription",
|
||||||
|
tenant_id=tenant_id)
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Check if subscription has a customer ID
|
||||||
|
if not subscription.customer_id:
|
||||||
|
logger.warning("get_payment_method_no_customer_id",
|
||||||
|
tenant_id=tenant_id)
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Get payment method from payment provider
|
||||||
|
payment_method = await self.payment_service.get_customer_payment_method(subscription.customer_id)
|
||||||
|
|
||||||
|
if not payment_method:
|
||||||
|
logger.info("get_payment_method_not_found",
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
customer_id=subscription.customer_id)
|
||||||
|
return None
|
||||||
|
|
||||||
|
logger.info("payment_method_retrieved",
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
payment_method_type=payment_method.type,
|
||||||
|
last4=payment_method.last4)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"brand": payment_method.brand,
|
||||||
|
"last4": payment_method.last4,
|
||||||
|
"exp_month": payment_method.exp_month,
|
||||||
|
"exp_year": payment_method.exp_year
|
||||||
|
}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("get_payment_method_failed",
|
||||||
|
error=str(e),
|
||||||
|
tenant_id=tenant_id)
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def update_payment_method(
|
||||||
|
self,
|
||||||
|
tenant_id: str,
|
||||||
|
payment_method_id: str
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Update the default payment method for a tenant's subscription
|
||||||
|
|
||||||
|
This is an orchestration method that coordinates between:
|
||||||
|
1. SubscriptionService (to get subscription data)
|
||||||
|
2. PaymentService (to update payment method with provider)
|
||||||
|
|
||||||
|
Args:
|
||||||
|
tenant_id: Tenant ID
|
||||||
|
payment_method_id: New payment method ID from frontend
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary with updated payment method details
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValidationError: If subscription or customer_id not found
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# Get subscription from database
|
||||||
|
subscription = await self.subscription_service.get_subscription_by_tenant_id(tenant_id)
|
||||||
|
|
||||||
|
if not subscription:
|
||||||
|
raise ValidationError(f"Subscription not found for tenant {tenant_id}")
|
||||||
|
|
||||||
|
if not subscription.customer_id:
|
||||||
|
raise ValidationError(f"Tenant {tenant_id} does not have a payment customer ID")
|
||||||
|
|
||||||
|
# Update payment method via payment provider
|
||||||
|
payment_result = await self.payment_service.update_payment_method(
|
||||||
|
subscription.customer_id,
|
||||||
|
payment_method_id
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info("payment_method_updated",
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
payment_method_id=payment_method_id,
|
||||||
|
requires_action=payment_result.get('requires_action', False))
|
||||||
|
|
||||||
|
pm_details = payment_result.get('payment_method', {})
|
||||||
|
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"message": "Payment method updated successfully",
|
||||||
|
"payment_method_id": pm_details.get('id'),
|
||||||
|
"brand": pm_details.get('brand', 'unknown'),
|
||||||
|
"last4": pm_details.get('last4', '0000'),
|
||||||
|
"exp_month": pm_details.get('exp_month'),
|
||||||
|
"exp_year": pm_details.get('exp_year'),
|
||||||
|
"requires_action": payment_result.get('requires_action', False),
|
||||||
|
"client_secret": payment_result.get('client_secret'),
|
||||||
|
"payment_intent_status": payment_result.get('payment_intent_status')
|
||||||
|
}
|
||||||
|
|
||||||
|
except ValidationError:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("update_payment_method_failed",
|
||||||
|
error=str(e),
|
||||||
|
tenant_id=tenant_id)
|
||||||
|
raise DatabaseError(f"Failed to update payment method: {str(e)}")
|
||||||
|
|
||||||
|
async def create_registration_payment_setup(
|
||||||
|
self,
|
||||||
|
user_data: Dict[str, Any],
|
||||||
|
plan_id: str,
|
||||||
|
payment_method_id: str,
|
||||||
|
billing_interval: str = "monthly",
|
||||||
|
coupon_code: Optional[str] = None
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Create payment customer and SetupIntent for registration (pre-user-creation)
|
||||||
|
|
||||||
|
This method supports the secure architecture where users are only created
|
||||||
|
after payment verification. It creates a payment customer and SetupIntent
|
||||||
|
without requiring a user_id.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user_data: User data (email, full_name, etc.) - NO user_id required
|
||||||
|
plan_id: Subscription plan ID
|
||||||
|
payment_method_id: Payment method ID from frontend
|
||||||
|
billing_interval: Billing interval (monthly/yearly)
|
||||||
|
coupon_code: Optional coupon code
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary with payment setup results including SetupIntent if required
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
Exception: If payment setup fails
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
logger.info("Starting registration payment setup (pre-user-creation)",
|
||||||
|
email=user_data.get('email'),
|
||||||
|
plan_id=plan_id)
|
||||||
|
|
||||||
|
# Step 1: Create payment customer (without user_id)
|
||||||
|
logger.info("Creating payment customer for registration",
|
||||||
|
email=user_data.get('email'))
|
||||||
|
|
||||||
|
# Create customer without user_id metadata
|
||||||
|
customer_data = {
|
||||||
|
'email': user_data.get('email'),
|
||||||
|
'name': user_data.get('full_name'),
|
||||||
|
'metadata': {
|
||||||
|
'registration_flow': 'pre_user_creation',
|
||||||
|
'timestamp': datetime.now(timezone.utc).isoformat()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
customer = await self.payment_service.create_customer(customer_data)
|
||||||
|
logger.info("Payment customer created for registration",
|
||||||
|
customer_id=customer.id,
|
||||||
|
email=user_data.get('email'))
|
||||||
|
|
||||||
|
# Step 2: Handle coupon logic (if provided)
|
||||||
|
trial_period_days = 0
|
||||||
|
coupon_discount = None
|
||||||
|
|
||||||
|
if coupon_code:
|
||||||
|
logger.info("Validating and redeeming coupon code for registration",
|
||||||
|
coupon_code=coupon_code,
|
||||||
|
email=user_data.get('email'))
|
||||||
|
|
||||||
|
coupon_service = CouponService(self.db_session)
|
||||||
|
success, discount_applied, error = await coupon_service.redeem_coupon(
|
||||||
|
coupon_code,
|
||||||
|
None, # No tenant_id yet
|
||||||
|
base_trial_days=0
|
||||||
|
)
|
||||||
|
|
||||||
|
if success and discount_applied:
|
||||||
|
coupon_discount = discount_applied
|
||||||
|
trial_period_days = discount_applied.get("total_trial_days", 0)
|
||||||
|
logger.info("Coupon redeemed successfully for registration",
|
||||||
|
coupon_code=coupon_code,
|
||||||
|
trial_period_days=trial_period_days)
|
||||||
|
else:
|
||||||
|
logger.warning("Failed to redeem coupon for registration, continuing without it",
|
||||||
|
coupon_code=coupon_code,
|
||||||
|
error=error)
|
||||||
|
|
||||||
|
# Step 3: Create subscription/SetupIntent
|
||||||
|
logger.info("Creating subscription/SetupIntent for registration",
|
||||||
|
customer_id=customer.id,
|
||||||
|
plan_id=plan_id,
|
||||||
|
payment_method_id=payment_method_id)
|
||||||
|
|
||||||
|
subscription_result = await self.payment_service.create_payment_subscription(
|
||||||
|
customer.id,
|
||||||
|
plan_id,
|
||||||
|
payment_method_id,
|
||||||
|
trial_period_days if trial_period_days > 0 else None,
|
||||||
|
billing_interval
|
||||||
|
)
|
||||||
|
|
||||||
|
# Check if result requires 3DS authentication (SetupIntent confirmation)
|
||||||
|
if isinstance(subscription_result, dict) and subscription_result.get('requires_action'):
|
||||||
|
logger.info("Registration payment setup requires SetupIntent confirmation",
|
||||||
|
customer_id=customer.id,
|
||||||
|
action_type=subscription_result.get('action_type'),
|
||||||
|
setup_intent_id=subscription_result.get('setup_intent_id'))
|
||||||
|
|
||||||
|
# Return the SetupIntent data for frontend to handle 3DS
|
||||||
|
return {
|
||||||
|
"requires_action": True,
|
||||||
|
"action_type": subscription_result.get('action_type'),
|
||||||
|
"client_secret": subscription_result.get('client_secret'),
|
||||||
|
"setup_intent_id": subscription_result.get('setup_intent_id'),
|
||||||
|
"customer_id": customer.id,
|
||||||
|
"payment_customer_id": customer.id,
|
||||||
|
"plan_id": plan_id,
|
||||||
|
"payment_method_id": payment_method_id,
|
||||||
|
"trial_period_days": trial_period_days,
|
||||||
|
"billing_interval": billing_interval,
|
||||||
|
"coupon_applied": coupon_code is not None,
|
||||||
|
"email": user_data.get('email'),
|
||||||
|
"full_name": user_data.get('full_name'),
|
||||||
|
"message": subscription_result.get('message') or "Payment verification required before account creation"
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
# No 3DS required - subscription created successfully
|
||||||
|
logger.info("Registration payment setup completed without 3DS",
|
||||||
|
customer_id=customer.id,
|
||||||
|
subscription_id=subscription_result.get('subscription_id'))
|
||||||
|
|
||||||
|
return {
|
||||||
|
"requires_action": False,
|
||||||
|
"subscription_id": subscription_result.get('subscription_id'),
|
||||||
|
"customer_id": customer.id,
|
||||||
|
"payment_customer_id": customer.id,
|
||||||
|
"plan_id": plan_id,
|
||||||
|
"payment_method_id": payment_method_id,
|
||||||
|
"trial_period_days": trial_period_days,
|
||||||
|
"billing_interval": billing_interval,
|
||||||
|
"coupon_applied": coupon_code is not None,
|
||||||
|
"email": user_data.get('email'),
|
||||||
|
"full_name": user_data.get('full_name'),
|
||||||
|
"message": "Payment setup completed successfully"
|
||||||
|
}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("Registration payment setup failed",
|
||||||
|
email=user_data.get('email'),
|
||||||
|
error=str(e),
|
||||||
|
exc_info=True)
|
||||||
|
raise
|
||||||
|
|
||||||
|
async def verify_setup_intent_for_registration(
|
||||||
|
self,
|
||||||
|
setup_intent_id: str
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Verify SetupIntent status for registration completion
|
||||||
|
|
||||||
|
This method checks if a SetupIntent has been successfully confirmed
|
||||||
|
(either automatically or via 3DS authentication) before proceeding
|
||||||
|
with user creation.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
setup_intent_id: SetupIntent ID to verify
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary with SetupIntent verification result
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
Exception: If verification fails
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
logger.info("Verifying SetupIntent for registration completion",
|
||||||
|
setup_intent_id=setup_intent_id)
|
||||||
|
|
||||||
|
# Use payment service to verify SetupIntent
|
||||||
|
verification_result = await self.payment_service.verify_setup_intent(setup_intent_id)
|
||||||
|
|
||||||
|
logger.info("SetupIntent verification result for registration",
|
||||||
|
setup_intent_id=setup_intent_id,
|
||||||
|
status=verification_result.get('status'))
|
||||||
|
|
||||||
|
return verification_result
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("SetupIntent verification failed for registration",
|
||||||
|
setup_intent_id=setup_intent_id,
|
||||||
|
error=str(e),
|
||||||
|
exc_info=True)
|
||||||
|
raise
|
||||||
|
|||||||
@@ -30,8 +30,8 @@ class SubscriptionService:
|
|||||||
async def create_subscription_record(
|
async def create_subscription_record(
|
||||||
self,
|
self,
|
||||||
tenant_id: str,
|
tenant_id: str,
|
||||||
stripe_subscription_id: str,
|
subscription_id: str,
|
||||||
stripe_customer_id: str,
|
customer_id: str,
|
||||||
plan: str,
|
plan: str,
|
||||||
status: str,
|
status: str,
|
||||||
trial_period_days: Optional[int] = None,
|
trial_period_days: Optional[int] = None,
|
||||||
@@ -42,8 +42,8 @@ class SubscriptionService:
|
|||||||
|
|
||||||
Args:
|
Args:
|
||||||
tenant_id: Tenant ID
|
tenant_id: Tenant ID
|
||||||
stripe_subscription_id: Stripe subscription ID
|
subscription_id: Payment provider subscription ID
|
||||||
stripe_customer_id: Stripe customer ID
|
customer_id: Payment provider customer ID
|
||||||
plan: Subscription plan
|
plan: Subscription plan
|
||||||
status: Subscription status
|
status: Subscription status
|
||||||
trial_period_days: Optional trial period in days
|
trial_period_days: Optional trial period in days
|
||||||
@@ -66,8 +66,8 @@ class SubscriptionService:
|
|||||||
# Create local subscription record
|
# Create local subscription record
|
||||||
subscription_data = {
|
subscription_data = {
|
||||||
'tenant_id': str(tenant_id),
|
'tenant_id': str(tenant_id),
|
||||||
'subscription_id': stripe_subscription_id, # Stripe subscription ID
|
'subscription_id': subscription_id,
|
||||||
'customer_id': stripe_customer_id, # Stripe customer ID
|
'customer_id': customer_id,
|
||||||
'plan_id': plan,
|
'plan_id': plan,
|
||||||
'status': status,
|
'status': status,
|
||||||
'created_at': datetime.now(timezone.utc),
|
'created_at': datetime.now(timezone.utc),
|
||||||
@@ -79,7 +79,7 @@ class SubscriptionService:
|
|||||||
|
|
||||||
logger.info("subscription_record_created",
|
logger.info("subscription_record_created",
|
||||||
tenant_id=tenant_id,
|
tenant_id=tenant_id,
|
||||||
subscription_id=stripe_subscription_id,
|
subscription_id=subscription_id,
|
||||||
plan=plan)
|
plan=plan)
|
||||||
|
|
||||||
return created_subscription
|
return created_subscription
|
||||||
@@ -181,24 +181,24 @@ class SubscriptionService:
|
|||||||
error=str(e), tenant_id=tenant_id)
|
error=str(e), tenant_id=tenant_id)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
async def get_subscription_by_stripe_id(
|
async def get_subscription_by_provider_id(
|
||||||
self,
|
self,
|
||||||
stripe_subscription_id: str
|
subscription_id: str
|
||||||
) -> Optional[Subscription]:
|
) -> Optional[Subscription]:
|
||||||
"""
|
"""
|
||||||
Get subscription by Stripe subscription ID
|
Get subscription by payment provider subscription ID
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
stripe_subscription_id: Stripe subscription ID
|
subscription_id: Payment provider subscription ID
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Subscription object or None
|
Subscription object or None
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
return await self.subscription_repo.get_by_stripe_id(stripe_subscription_id)
|
return await self.subscription_repo.get_by_provider_id(subscription_id)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error("get_subscription_by_stripe_id_failed",
|
logger.error("get_subscription_by_provider_id_failed",
|
||||||
error=str(e), stripe_subscription_id=stripe_subscription_id)
|
error=str(e), subscription_id=subscription_id)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
async def cancel_subscription(
|
async def cancel_subscription(
|
||||||
@@ -587,8 +587,8 @@ class SubscriptionService:
|
|||||||
|
|
||||||
async def create_tenant_independent_subscription_record(
|
async def create_tenant_independent_subscription_record(
|
||||||
self,
|
self,
|
||||||
stripe_subscription_id: str,
|
subscription_id: str,
|
||||||
stripe_customer_id: str,
|
customer_id: str,
|
||||||
plan: str,
|
plan: str,
|
||||||
status: str,
|
status: str,
|
||||||
trial_period_days: Optional[int] = None,
|
trial_period_days: Optional[int] = None,
|
||||||
@@ -601,8 +601,8 @@ class SubscriptionService:
|
|||||||
This subscription is not linked to any tenant and will be linked during onboarding
|
This subscription is not linked to any tenant and will be linked during onboarding
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
stripe_subscription_id: Stripe subscription ID
|
subscription_id: Payment provider subscription ID
|
||||||
stripe_customer_id: Stripe customer ID
|
customer_id: Payment provider customer ID
|
||||||
plan: Subscription plan
|
plan: Subscription plan
|
||||||
status: Subscription status
|
status: Subscription status
|
||||||
trial_period_days: Optional trial period in days
|
trial_period_days: Optional trial period in days
|
||||||
@@ -615,8 +615,8 @@ class SubscriptionService:
|
|||||||
try:
|
try:
|
||||||
# Create tenant-independent subscription record
|
# Create tenant-independent subscription record
|
||||||
subscription_data = {
|
subscription_data = {
|
||||||
'stripe_subscription_id': stripe_subscription_id, # Stripe subscription ID
|
'subscription_id': subscription_id,
|
||||||
'stripe_customer_id': stripe_customer_id, # Stripe customer ID
|
'customer_id': customer_id,
|
||||||
'plan': plan, # Repository expects 'plan', not 'plan_id'
|
'plan': plan, # Repository expects 'plan', not 'plan_id'
|
||||||
'status': status,
|
'status': status,
|
||||||
'created_at': datetime.now(timezone.utc),
|
'created_at': datetime.now(timezone.utc),
|
||||||
@@ -630,7 +630,7 @@ class SubscriptionService:
|
|||||||
created_subscription = await self.subscription_repo.create_tenant_independent_subscription(subscription_data)
|
created_subscription = await self.subscription_repo.create_tenant_independent_subscription(subscription_data)
|
||||||
|
|
||||||
logger.info("tenant_independent_subscription_record_created",
|
logger.info("tenant_independent_subscription_record_created",
|
||||||
subscription_id=stripe_subscription_id,
|
subscription_id=subscription_id,
|
||||||
user_id=user_id,
|
user_id=user_id,
|
||||||
plan=plan)
|
plan=plan)
|
||||||
|
|
||||||
|
|||||||
@@ -1445,7 +1445,7 @@ class EnhancedTenantService:
|
|||||||
|
|
||||||
# Update tenant with subscription information
|
# Update tenant with subscription information
|
||||||
tenant_update = {
|
tenant_update = {
|
||||||
"stripe_customer_id": subscription.customer_id,
|
"customer_id": subscription.customer_id,
|
||||||
"subscription_status": subscription.status,
|
"subscription_status": subscription.status,
|
||||||
"subscription_plan": subscription.plan,
|
"subscription_plan": subscription.plan,
|
||||||
"subscription_tier": subscription.plan,
|
"subscription_tier": subscription.plan,
|
||||||
|
|||||||
@@ -204,8 +204,8 @@ def upgrade() -> None:
|
|||||||
sa.Column('trial_ends_at', sa.DateTime(timezone=True), nullable=True),
|
sa.Column('trial_ends_at', sa.DateTime(timezone=True), nullable=True),
|
||||||
sa.Column('cancelled_at', sa.DateTime(timezone=True), nullable=True),
|
sa.Column('cancelled_at', sa.DateTime(timezone=True), nullable=True),
|
||||||
sa.Column('cancellation_effective_date', sa.DateTime(timezone=True), nullable=True),
|
sa.Column('cancellation_effective_date', sa.DateTime(timezone=True), nullable=True),
|
||||||
sa.Column('stripe_subscription_id', sa.String(255), nullable=True),
|
sa.Column('subscription_id', sa.String(255), nullable=True),
|
||||||
sa.Column('stripe_customer_id', sa.String(255), nullable=True),
|
sa.Column('customer_id', sa.String(255), nullable=True),
|
||||||
# Basic resource limits
|
# Basic resource limits
|
||||||
sa.Column('max_users', sa.Integer(), nullable=True),
|
sa.Column('max_users', sa.Integer(), nullable=True),
|
||||||
sa.Column('max_locations', sa.Integer(), nullable=True),
|
sa.Column('max_locations', sa.Integer(), nullable=True),
|
||||||
@@ -284,24 +284,24 @@ def upgrade() -> None:
|
|||||||
WHERE status = 'active'
|
WHERE status = 'active'
|
||||||
""")
|
""")
|
||||||
|
|
||||||
# Index 5: Stripe subscription lookup (for webhook processing)
|
# Index 5: Subscription ID lookup (for webhook processing)
|
||||||
if not _index_exists(connection, 'idx_subscriptions_stripe_sub_id'):
|
if not _index_exists(connection, 'idx_subscriptions_subscription_id'):
|
||||||
op.create_index(
|
op.create_index(
|
||||||
'idx_subscriptions_stripe_sub_id',
|
'idx_subscriptions_subscription_id',
|
||||||
'subscriptions',
|
'subscriptions',
|
||||||
['stripe_subscription_id'],
|
['subscription_id'],
|
||||||
unique=False,
|
unique=False,
|
||||||
postgresql_where=sa.text("stripe_subscription_id IS NOT NULL")
|
postgresql_where=sa.text("subscription_id IS NOT NULL")
|
||||||
)
|
)
|
||||||
|
|
||||||
# Index 6: Stripe customer lookup (for customer-related operations)
|
# Index 6: Customer ID lookup (for customer-related operations)
|
||||||
if not _index_exists(connection, 'idx_subscriptions_stripe_customer_id'):
|
if not _index_exists(connection, 'idx_subscriptions_customer_id'):
|
||||||
op.create_index(
|
op.create_index(
|
||||||
'idx_subscriptions_stripe_customer_id',
|
'idx_subscriptions_customer_id',
|
||||||
'subscriptions',
|
'subscriptions',
|
||||||
['stripe_customer_id'],
|
['customer_id'],
|
||||||
unique=False,
|
unique=False,
|
||||||
postgresql_where=sa.text("stripe_customer_id IS NOT NULL")
|
postgresql_where=sa.text("customer_id IS NOT NULL")
|
||||||
)
|
)
|
||||||
|
|
||||||
# Index 7: User ID for tenant linking
|
# Index 7: User ID for tenant linking
|
||||||
@@ -481,8 +481,8 @@ def downgrade() -> None:
|
|||||||
# Drop subscriptions table indexes first
|
# Drop subscriptions table indexes first
|
||||||
op.drop_index('idx_subscriptions_linking_status', table_name='subscriptions')
|
op.drop_index('idx_subscriptions_linking_status', table_name='subscriptions')
|
||||||
op.drop_index('idx_subscriptions_user_id', table_name='subscriptions')
|
op.drop_index('idx_subscriptions_user_id', table_name='subscriptions')
|
||||||
op.drop_index('idx_subscriptions_stripe_customer_id', table_name='subscriptions')
|
op.drop_index('idx_subscriptions_customer_id', table_name='subscriptions')
|
||||||
op.drop_index('idx_subscriptions_stripe_sub_id', table_name='subscriptions')
|
op.drop_index('idx_subscriptions_subscription_id', table_name='subscriptions')
|
||||||
op.drop_index('idx_subscriptions_active_tenant', table_name='subscriptions')
|
op.drop_index('idx_subscriptions_active_tenant', table_name='subscriptions')
|
||||||
op.drop_index('idx_subscriptions_status_billing', table_name='subscriptions')
|
op.drop_index('idx_subscriptions_status_billing', table_name='subscriptions')
|
||||||
op.drop_index('idx_subscriptions_tenant_covering', table_name='subscriptions')
|
op.drop_index('idx_subscriptions_tenant_covering', table_name='subscriptions')
|
||||||
|
|||||||
@@ -38,6 +38,13 @@ class Subscription:
|
|||||||
created_at: datetime
|
created_at: datetime
|
||||||
billing_cycle_anchor: Optional[datetime] = None
|
billing_cycle_anchor: Optional[datetime] = None
|
||||||
cancel_at_period_end: Optional[bool] = None
|
cancel_at_period_end: Optional[bool] = None
|
||||||
|
# 3DS Authentication fields
|
||||||
|
payment_intent_id: Optional[str] = None
|
||||||
|
payment_intent_status: Optional[str] = None
|
||||||
|
payment_intent_client_secret: Optional[str] = None
|
||||||
|
requires_action: Optional[bool] = None
|
||||||
|
trial_end: Optional[datetime] = None
|
||||||
|
billing_interval: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
|
|||||||
@@ -15,6 +15,11 @@ from .payment_client import PaymentProvider, PaymentCustomer, PaymentMethod, Sub
|
|||||||
logger = structlog.get_logger()
|
logger = structlog.get_logger()
|
||||||
|
|
||||||
|
|
||||||
|
class PaymentVerificationError(Exception):
|
||||||
|
"""Exception raised when payment method verification fails"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
class StripeProvider(PaymentProvider):
|
class StripeProvider(PaymentProvider):
|
||||||
"""
|
"""
|
||||||
Stripe implementation of the PaymentProvider interface
|
Stripe implementation of the PaymentProvider interface
|
||||||
@@ -62,20 +67,23 @@ class StripeProvider(PaymentProvider):
|
|||||||
email=customer_data.get('email'))
|
email=customer_data.get('email'))
|
||||||
raise e
|
raise e
|
||||||
|
|
||||||
async def create_subscription(self, customer_id: str, plan_id: str, payment_method_id: str, trial_period_days: Optional[int] = None) -> Subscription:
|
async def create_subscription(self, customer_id: str, plan_id: str, payment_method_id: str, trial_period_days: Optional[int] = None) -> Dict[str, Any]:
|
||||||
"""
|
"""
|
||||||
Create a subscription in Stripe with idempotency and enhanced error handling
|
Create a subscription in Stripe with idempotency and enhanced error handling
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary containing subscription details and any required authentication actions
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
subscription_idempotency_key = f"create_subscription_{uuid.uuid4()}"
|
subscription_idempotency_key = f"create_subscription_{uuid.uuid4()}"
|
||||||
payment_method_idempotency_key = f"attach_pm_{uuid.uuid4()}"
|
payment_method_idempotency_key = f"attach_pm_{uuid.uuid4()}"
|
||||||
customer_update_idempotency_key = f"update_customer_{uuid.uuid4()}"
|
customer_update_idempotency_key = f"update_customer_{uuid.uuid4()}"
|
||||||
|
|
||||||
logger.info("Creating Stripe subscription",
|
logger.info("Creating Stripe subscription",
|
||||||
customer_id=customer_id,
|
customer_id=customer_id,
|
||||||
plan_id=plan_id,
|
plan_id=plan_id,
|
||||||
payment_method_id=payment_method_id)
|
payment_method_id=payment_method_id)
|
||||||
|
|
||||||
# Attach payment method to customer with idempotency and error handling
|
# Attach payment method to customer with idempotency and error handling
|
||||||
try:
|
try:
|
||||||
stripe.PaymentMethod.attach(
|
stripe.PaymentMethod.attach(
|
||||||
@@ -94,7 +102,7 @@ class StripeProvider(PaymentProvider):
|
|||||||
payment_method_id=payment_method_id)
|
payment_method_id=payment_method_id)
|
||||||
else:
|
else:
|
||||||
raise
|
raise
|
||||||
|
|
||||||
# Set customer's default payment method with idempotency
|
# Set customer's default payment method with idempotency
|
||||||
stripe.Customer.modify(
|
stripe.Customer.modify(
|
||||||
customer_id,
|
customer_id,
|
||||||
@@ -103,10 +111,68 @@ class StripeProvider(PaymentProvider):
|
|||||||
},
|
},
|
||||||
idempotency_key=customer_update_idempotency_key
|
idempotency_key=customer_update_idempotency_key
|
||||||
)
|
)
|
||||||
|
|
||||||
logger.info("Customer default payment method updated",
|
logger.info("Customer default payment method updated",
|
||||||
customer_id=customer_id)
|
customer_id=customer_id)
|
||||||
|
|
||||||
|
# Verify payment method before creating subscription (especially important for trial periods)
|
||||||
|
# This ensures the card is valid and can be charged after the trial
|
||||||
|
# Use SetupIntent for card verification without immediate payment
|
||||||
|
#
|
||||||
|
# CRITICAL FOR 3DS SUPPORT:
|
||||||
|
# We do NOT confirm the SetupIntent here because:
|
||||||
|
# 1. If 3DS is required, we need the frontend to handle the redirect
|
||||||
|
# 2. The frontend will confirm the SetupIntent with the return_url
|
||||||
|
# 3. After 3DS completion, frontend will call us again with the verified payment method
|
||||||
|
#
|
||||||
|
# This prevents creating subscriptions with unverified payment methods.
|
||||||
|
|
||||||
|
setup_intent_params = {
|
||||||
|
'customer': customer_id,
|
||||||
|
'payment_method': payment_method_id,
|
||||||
|
'usage': 'off_session', # Allow charging without customer presence after verification
|
||||||
|
'idempotency_key': f"verify_payment_{uuid.uuid4()}",
|
||||||
|
'expand': ['payment_method'],
|
||||||
|
'confirm': False # Explicitly don't confirm yet - frontend will handle 3DS
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Create SetupIntent WITHOUT confirming
|
||||||
|
# Frontend will confirm with return_url if 3DS is needed
|
||||||
|
verification_intent = stripe.SetupIntent.create(**setup_intent_params)
|
||||||
|
|
||||||
|
logger.info("SetupIntent created for payment method verification",
|
||||||
|
setup_intent_id=verification_intent.id,
|
||||||
|
status=verification_intent.status,
|
||||||
|
payment_method_id=payment_method_id)
|
||||||
|
|
||||||
|
# ALWAYS return the SetupIntent for frontend to confirm
|
||||||
|
# Frontend will handle 3DS if needed, or confirm immediately if not
|
||||||
|
# This ensures proper 3DS flow for all cards
|
||||||
|
return {
|
||||||
|
'requires_action': True, # Frontend must always confirm
|
||||||
|
'action_type': 'setup_intent_confirmation',
|
||||||
|
'client_secret': verification_intent.client_secret,
|
||||||
|
'setup_intent_id': verification_intent.id,
|
||||||
|
'status': verification_intent.status,
|
||||||
|
'customer_id': customer_id,
|
||||||
|
'payment_method_id': payment_method_id,
|
||||||
|
'plan_id': plan_id,
|
||||||
|
'trial_period_days': trial_period_days,
|
||||||
|
'message': 'Payment method verification required. Frontend must confirm SetupIntent.'
|
||||||
|
}
|
||||||
|
|
||||||
|
except stripe.error.CardError as e:
|
||||||
|
logger.error("Payment method verification failed",
|
||||||
|
error=str(e),
|
||||||
|
code=e.code,
|
||||||
|
decline_code=e.decline_code)
|
||||||
|
raise PaymentVerificationError(f"Card verification failed: {e.user_message or str(e)}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("Unexpected error during payment verification",
|
||||||
|
error=str(e))
|
||||||
|
raise PaymentVerificationError(f"Payment verification error: {str(e)}")
|
||||||
|
|
||||||
# Create subscription with trial period if specified
|
# Create subscription with trial period if specified
|
||||||
subscription_params = {
|
subscription_params = {
|
||||||
'customer': customer_id,
|
'customer': customer_id,
|
||||||
@@ -115,29 +181,28 @@ class StripeProvider(PaymentProvider):
|
|||||||
'idempotency_key': subscription_idempotency_key,
|
'idempotency_key': subscription_idempotency_key,
|
||||||
'expand': ['latest_invoice.payment_intent']
|
'expand': ['latest_invoice.payment_intent']
|
||||||
}
|
}
|
||||||
|
|
||||||
if trial_period_days:
|
if trial_period_days:
|
||||||
subscription_params['trial_period_days'] = trial_period_days
|
subscription_params['trial_period_days'] = trial_period_days
|
||||||
logger.info("Subscription includes trial period",
|
logger.info("Subscription includes trial period",
|
||||||
trial_period_days=trial_period_days)
|
trial_period_days=trial_period_days)
|
||||||
|
|
||||||
stripe_subscription = stripe.Subscription.create(**subscription_params)
|
stripe_subscription = stripe.Subscription.create(**subscription_params)
|
||||||
|
|
||||||
# Handle period dates for trial vs active subscriptions
|
# Handle period dates for trial vs active subscriptions
|
||||||
# During trial: current_period_* fields are only in subscription items, not root
|
if stripe_subscription.status == 'trialing' and hasattr(stripe_subscription, 'items') and stripe_subscription.items:
|
||||||
# After trial: current_period_* fields are at root level
|
# Access items properly - stripe_subscription.items is typically a list-like object
|
||||||
if stripe_subscription.status == 'trialing' and stripe_subscription.items and stripe_subscription.items.data:
|
items_list = stripe_subscription.items.data if hasattr(stripe_subscription.items, 'data') else stripe_subscription.items
|
||||||
# For trial subscriptions, get period from first subscription item
|
if items_list and len(items_list) > 0:
|
||||||
first_item = stripe_subscription.items.data[0]
|
first_item = items_list[0] if isinstance(items_list, list) else items_list
|
||||||
current_period_start = first_item.current_period_start
|
current_period_start = first_item.current_period_start
|
||||||
current_period_end = first_item.current_period_end
|
current_period_end = first_item.current_period_end
|
||||||
logger.info("Stripe trial subscription created successfully",
|
logger.info("Stripe trial subscription created successfully",
|
||||||
subscription_id=stripe_subscription.id,
|
subscription_id=stripe_subscription.id,
|
||||||
status=stripe_subscription.status,
|
status=stripe_subscription.status,
|
||||||
trial_end=stripe_subscription.trial_end,
|
trial_end=stripe_subscription.trial_end,
|
||||||
current_period_end=current_period_end)
|
current_period_end=current_period_end)
|
||||||
else:
|
else:
|
||||||
# For active subscriptions, get period from root level
|
|
||||||
current_period_start = stripe_subscription.current_period_start
|
current_period_start = stripe_subscription.current_period_start
|
||||||
current_period_end = stripe_subscription.current_period_end
|
current_period_end = stripe_subscription.current_period_end
|
||||||
logger.info("Stripe subscription created successfully",
|
logger.info("Stripe subscription created successfully",
|
||||||
@@ -145,15 +210,53 @@ class StripeProvider(PaymentProvider):
|
|||||||
status=stripe_subscription.status,
|
status=stripe_subscription.status,
|
||||||
current_period_end=current_period_end)
|
current_period_end=current_period_end)
|
||||||
|
|
||||||
return Subscription(
|
# Check if payment requires action (3D Secure, SCA)
|
||||||
|
requires_action = False
|
||||||
|
client_secret = None
|
||||||
|
payment_intent_status = None
|
||||||
|
|
||||||
|
if stripe_subscription.latest_invoice:
|
||||||
|
if hasattr(stripe_subscription.latest_invoice, 'payment_intent') and stripe_subscription.latest_invoice.payment_intent:
|
||||||
|
payment_intent = stripe_subscription.latest_invoice.payment_intent
|
||||||
|
payment_intent_status = payment_intent.status
|
||||||
|
|
||||||
|
if payment_intent.status in ['requires_action', 'requires_source_action']:
|
||||||
|
requires_action = True
|
||||||
|
client_secret = payment_intent.client_secret
|
||||||
|
logger.info("Subscription payment requires authentication",
|
||||||
|
subscription_id=stripe_subscription.id,
|
||||||
|
payment_intent_id=payment_intent.id,
|
||||||
|
status=payment_intent.status)
|
||||||
|
|
||||||
|
# Calculate trial end if this is a trial subscription
|
||||||
|
trial_end_timestamp = None
|
||||||
|
if stripe_subscription.status == 'trialing' and hasattr(stripe_subscription, 'trial_end'):
|
||||||
|
trial_end_timestamp = stripe_subscription.trial_end
|
||||||
|
|
||||||
|
subscription_obj = Subscription(
|
||||||
id=stripe_subscription.id,
|
id=stripe_subscription.id,
|
||||||
customer_id=stripe_subscription.customer,
|
customer_id=stripe_subscription.customer,
|
||||||
plan_id=plan_id, # Using the price ID as plan_id
|
plan_id=plan_id,
|
||||||
status=stripe_subscription.status,
|
status=stripe_subscription.status,
|
||||||
current_period_start=datetime.fromtimestamp(current_period_start),
|
current_period_start=datetime.fromtimestamp(current_period_start),
|
||||||
current_period_end=datetime.fromtimestamp(current_period_end),
|
current_period_end=datetime.fromtimestamp(current_period_end),
|
||||||
created_at=datetime.fromtimestamp(stripe_subscription.created)
|
created_at=datetime.fromtimestamp(stripe_subscription.created),
|
||||||
|
billing_cycle_anchor=datetime.fromtimestamp(stripe_subscription.billing_cycle_anchor) if hasattr(stripe_subscription, 'billing_cycle_anchor') else None,
|
||||||
|
cancel_at_period_end=stripe_subscription.cancel_at_period_end if hasattr(stripe_subscription, 'cancel_at_period_end') else None,
|
||||||
|
payment_intent_id=stripe_subscription.latest_invoice.payment_intent.id if (hasattr(stripe_subscription.latest_invoice, 'payment_intent') and stripe_subscription.latest_invoice.payment_intent) else None,
|
||||||
|
payment_intent_status=payment_intent_status,
|
||||||
|
payment_intent_client_secret=client_secret,
|
||||||
|
requires_action=requires_action,
|
||||||
|
trial_end=datetime.fromtimestamp(trial_end_timestamp) if trial_end_timestamp else None,
|
||||||
|
billing_interval="monthly" # Default, should be extracted from plan
|
||||||
)
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
'subscription': subscription_obj,
|
||||||
|
'requires_action': requires_action,
|
||||||
|
'client_secret': client_secret,
|
||||||
|
'payment_intent_status': payment_intent_status
|
||||||
|
}
|
||||||
except stripe.error.CardError as e:
|
except stripe.error.CardError as e:
|
||||||
logger.error("Card error during subscription creation",
|
logger.error("Card error during subscription creation",
|
||||||
error=str(e),
|
error=str(e),
|
||||||
@@ -175,9 +278,134 @@ class StripeProvider(PaymentProvider):
|
|||||||
plan_id=plan_id)
|
plan_id=plan_id)
|
||||||
raise e
|
raise e
|
||||||
|
|
||||||
async def update_payment_method(self, customer_id: str, payment_method_id: str) -> PaymentMethod:
|
async def complete_subscription_after_setup_intent(
|
||||||
|
self,
|
||||||
|
setup_intent_id: str,
|
||||||
|
customer_id: str,
|
||||||
|
plan_id: str,
|
||||||
|
payment_method_id: str,
|
||||||
|
trial_period_days: Optional[int] = None
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Complete subscription creation after SetupIntent has been confirmed by frontend
|
||||||
|
|
||||||
|
This method should be called after the frontend successfully confirms a SetupIntent
|
||||||
|
(with or without 3DS). It verifies the SetupIntent is in 'succeeded' status and
|
||||||
|
then creates the subscription with the now-verified payment method.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
setup_intent_id: The SetupIntent ID that was confirmed
|
||||||
|
customer_id: Stripe customer ID
|
||||||
|
plan_id: Subscription plan/price ID
|
||||||
|
payment_method_id: Payment method ID (should match SetupIntent)
|
||||||
|
trial_period_days: Optional trial period
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary containing subscription details
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
subscription_idempotency_key = f"complete_subscription_{uuid.uuid4()}"
|
||||||
|
|
||||||
|
logger.info("Completing subscription after SetupIntent confirmation",
|
||||||
|
setup_intent_id=setup_intent_id,
|
||||||
|
customer_id=customer_id,
|
||||||
|
plan_id=plan_id)
|
||||||
|
|
||||||
|
# Verify the SetupIntent is in succeeded status
|
||||||
|
setup_intent = stripe.SetupIntent.retrieve(setup_intent_id)
|
||||||
|
|
||||||
|
if setup_intent.status != 'succeeded':
|
||||||
|
logger.error("SetupIntent not in succeeded status",
|
||||||
|
setup_intent_id=setup_intent_id,
|
||||||
|
status=setup_intent.status)
|
||||||
|
raise PaymentVerificationError(
|
||||||
|
f"SetupIntent must be in 'succeeded' status. Current status: {setup_intent.status}"
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info("SetupIntent verified as succeeded, creating subscription",
|
||||||
|
setup_intent_id=setup_intent_id)
|
||||||
|
|
||||||
|
# Payment method is already attached and verified via SetupIntent
|
||||||
|
# Now create the subscription
|
||||||
|
subscription_params = {
|
||||||
|
'customer': customer_id,
|
||||||
|
'items': [{'price': plan_id}],
|
||||||
|
'default_payment_method': payment_method_id,
|
||||||
|
'idempotency_key': subscription_idempotency_key,
|
||||||
|
'expand': ['latest_invoice.payment_intent']
|
||||||
|
}
|
||||||
|
|
||||||
|
if trial_period_days:
|
||||||
|
subscription_params['trial_period_days'] = trial_period_days
|
||||||
|
logger.info("Subscription includes trial period",
|
||||||
|
trial_period_days=trial_period_days)
|
||||||
|
|
||||||
|
stripe_subscription = stripe.Subscription.create(**subscription_params)
|
||||||
|
|
||||||
|
# Handle period dates for trial vs active subscriptions
|
||||||
|
if stripe_subscription.status == 'trialing' and hasattr(stripe_subscription, 'items') and stripe_subscription.items:
|
||||||
|
items_list = stripe_subscription.items.data if hasattr(stripe_subscription.items, 'data') else stripe_subscription.items
|
||||||
|
if items_list and len(items_list) > 0:
|
||||||
|
first_item = items_list[0] if isinstance(items_list, list) else items_list
|
||||||
|
current_period_start = first_item.current_period_start
|
||||||
|
current_period_end = first_item.current_period_end
|
||||||
|
logger.info("Stripe trial subscription created after SetupIntent",
|
||||||
|
subscription_id=stripe_subscription.id,
|
||||||
|
status=stripe_subscription.status,
|
||||||
|
trial_end=stripe_subscription.trial_end,
|
||||||
|
current_period_end=current_period_end)
|
||||||
|
else:
|
||||||
|
current_period_start = stripe_subscription.current_period_start
|
||||||
|
current_period_end = stripe_subscription.current_period_end
|
||||||
|
logger.info("Stripe subscription created after SetupIntent",
|
||||||
|
subscription_id=stripe_subscription.id,
|
||||||
|
status=stripe_subscription.status,
|
||||||
|
current_period_end=current_period_end)
|
||||||
|
|
||||||
|
# Calculate trial end if this is a trial subscription
|
||||||
|
trial_end_timestamp = None
|
||||||
|
if stripe_subscription.status == 'trialing' and hasattr(stripe_subscription, 'trial_end'):
|
||||||
|
trial_end_timestamp = stripe_subscription.trial_end
|
||||||
|
|
||||||
|
subscription_obj = Subscription(
|
||||||
|
id=stripe_subscription.id,
|
||||||
|
customer_id=stripe_subscription.customer,
|
||||||
|
plan_id=plan_id,
|
||||||
|
status=stripe_subscription.status,
|
||||||
|
current_period_start=datetime.fromtimestamp(current_period_start),
|
||||||
|
current_period_end=datetime.fromtimestamp(current_period_end),
|
||||||
|
created_at=datetime.fromtimestamp(stripe_subscription.created),
|
||||||
|
billing_cycle_anchor=datetime.fromtimestamp(stripe_subscription.billing_cycle_anchor) if hasattr(stripe_subscription, 'billing_cycle_anchor') else None,
|
||||||
|
cancel_at_period_end=stripe_subscription.cancel_at_period_end if hasattr(stripe_subscription, 'cancel_at_period_end') else None,
|
||||||
|
payment_intent_id=stripe_subscription.latest_invoice.payment_intent.id if (hasattr(stripe_subscription.latest_invoice, 'payment_intent') and stripe_subscription.latest_invoice.payment_intent) else None,
|
||||||
|
payment_intent_status=None,
|
||||||
|
payment_intent_client_secret=None,
|
||||||
|
requires_action=False,
|
||||||
|
trial_end=datetime.fromtimestamp(trial_end_timestamp) if trial_end_timestamp else None,
|
||||||
|
billing_interval="monthly"
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
'subscription': subscription_obj,
|
||||||
|
'requires_action': False,
|
||||||
|
'client_secret': None,
|
||||||
|
'payment_intent_status': None,
|
||||||
|
'setup_intent_id': setup_intent_id
|
||||||
|
}
|
||||||
|
|
||||||
|
except stripe.error.StripeError as e:
|
||||||
|
logger.error("Failed to complete subscription after SetupIntent",
|
||||||
|
error=str(e),
|
||||||
|
setup_intent_id=setup_intent_id,
|
||||||
|
customer_id=customer_id)
|
||||||
|
raise e
|
||||||
|
|
||||||
|
async def update_payment_method(self, customer_id: str, payment_method_id: str) -> Dict[str, Any]:
|
||||||
"""
|
"""
|
||||||
Update the payment method for a customer in Stripe
|
Update the payment method for a customer in Stripe
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary containing payment method details and any required authentication actions
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
# Attach payment method to customer with error handling
|
# Attach payment method to customer with error handling
|
||||||
@@ -199,23 +427,58 @@ class StripeProvider(PaymentProvider):
|
|||||||
raise
|
raise
|
||||||
|
|
||||||
# Set as default payment method
|
# Set as default payment method
|
||||||
stripe.Customer.modify(
|
customer = stripe.Customer.modify(
|
||||||
customer_id,
|
customer_id,
|
||||||
invoice_settings={
|
invoice_settings={
|
||||||
'default_payment_method': payment_method_id
|
'default_payment_method': payment_method_id
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
stripe_payment_method = stripe.PaymentMethod.retrieve(payment_method_id)
|
stripe_payment_method = stripe.PaymentMethod.retrieve(payment_method_id)
|
||||||
|
|
||||||
return PaymentMethod(
|
# Get any active subscriptions that might need payment confirmation
|
||||||
id=stripe_payment_method.id,
|
subscriptions = stripe.Subscription.list(customer=customer_id, status='active', limit=1)
|
||||||
type=stripe_payment_method.type,
|
|
||||||
brand=getattr(stripe_payment_method, stripe_payment_method.type, {}).get('brand'),
|
requires_action = False
|
||||||
last4=getattr(stripe_payment_method, stripe_payment_method.type, {}).get('last4'),
|
client_secret = None
|
||||||
exp_month=getattr(stripe_payment_method, stripe_payment_method.type, {}).get('exp_month'),
|
payment_intent_status = None
|
||||||
exp_year=getattr(stripe_payment_method, stripe_payment_method.type, {}).get('exp_year'),
|
|
||||||
)
|
# Check if there's a subscription with pending payment that requires action
|
||||||
|
if subscriptions.data:
|
||||||
|
subscription = subscriptions.data[0]
|
||||||
|
if subscription.latest_invoice:
|
||||||
|
invoice = stripe.Invoice.retrieve(
|
||||||
|
subscription.latest_invoice,
|
||||||
|
expand=['payment_intent']
|
||||||
|
)
|
||||||
|
|
||||||
|
if invoice.payment_intent:
|
||||||
|
payment_intent = invoice.payment_intent
|
||||||
|
payment_intent_status = payment_intent.status
|
||||||
|
|
||||||
|
if payment_intent.status in ['requires_action', 'requires_source_action']:
|
||||||
|
requires_action = True
|
||||||
|
client_secret = payment_intent.client_secret
|
||||||
|
logger.info("Payment requires authentication",
|
||||||
|
customer_id=customer_id,
|
||||||
|
payment_intent_id=payment_intent.id,
|
||||||
|
status=payment_intent.status)
|
||||||
|
|
||||||
|
payment_method_details = getattr(stripe_payment_method, stripe_payment_method.type, {})
|
||||||
|
|
||||||
|
return {
|
||||||
|
'payment_method': {
|
||||||
|
'id': stripe_payment_method.id,
|
||||||
|
'type': stripe_payment_method.type,
|
||||||
|
'brand': payment_method_details.get('brand') if isinstance(payment_method_details, dict) else getattr(payment_method_details, 'brand', None),
|
||||||
|
'last4': payment_method_details.get('last4') if isinstance(payment_method_details, dict) else getattr(payment_method_details, 'last4', None),
|
||||||
|
'exp_month': payment_method_details.get('exp_month') if isinstance(payment_method_details, dict) else getattr(payment_method_details, 'exp_month', None),
|
||||||
|
'exp_year': payment_method_details.get('exp_year') if isinstance(payment_method_details, dict) else getattr(payment_method_details, 'exp_year', None),
|
||||||
|
},
|
||||||
|
'requires_action': requires_action,
|
||||||
|
'client_secret': client_secret,
|
||||||
|
'payment_intent_status': payment_intent_status
|
||||||
|
}
|
||||||
except stripe.error.StripeError as e:
|
except stripe.error.StripeError as e:
|
||||||
logger.error("Failed to update Stripe payment method", error=str(e))
|
logger.error("Failed to update Stripe payment method", error=str(e))
|
||||||
raise e
|
raise e
|
||||||
@@ -253,10 +516,12 @@ class StripeProvider(PaymentProvider):
|
|||||||
subscription_id=subscription_id)
|
subscription_id=subscription_id)
|
||||||
|
|
||||||
# Handle period dates for trial vs active subscriptions
|
# Handle period dates for trial vs active subscriptions
|
||||||
if stripe_subscription.status == 'trialing' and stripe_subscription.items and stripe_subscription.items.data:
|
if stripe_subscription.status == 'trialing' and hasattr(stripe_subscription, 'items') and stripe_subscription.items:
|
||||||
first_item = stripe_subscription.items.data[0]
|
items_list = stripe_subscription.items.data if hasattr(stripe_subscription.items, 'data') else stripe_subscription.items
|
||||||
current_period_start = first_item.current_period_start
|
if items_list and len(items_list) > 0:
|
||||||
current_period_end = first_item.current_period_end
|
first_item = items_list[0] if isinstance(items_list, list) else items_list
|
||||||
|
current_period_start = first_item.current_period_start
|
||||||
|
current_period_end = first_item.current_period_end
|
||||||
else:
|
else:
|
||||||
current_period_start = stripe_subscription.current_period_start
|
current_period_start = stripe_subscription.current_period_start
|
||||||
current_period_end = stripe_subscription.current_period_end
|
current_period_end = stripe_subscription.current_period_end
|
||||||
@@ -316,17 +581,22 @@ class StripeProvider(PaymentProvider):
|
|||||||
|
|
||||||
# Get the actual plan ID from the subscription items
|
# Get the actual plan ID from the subscription items
|
||||||
plan_id = subscription_id # Default fallback
|
plan_id = subscription_id # Default fallback
|
||||||
if stripe_subscription.items and stripe_subscription.items.data:
|
if hasattr(stripe_subscription, 'items') and stripe_subscription.items:
|
||||||
plan_id = stripe_subscription.items.data[0].price.id
|
items_list = stripe_subscription.items.data if hasattr(stripe_subscription.items, 'data') else stripe_subscription.items
|
||||||
|
if items_list and len(items_list) > 0:
|
||||||
|
first_item = items_list[0] if isinstance(items_list, list) else items_list
|
||||||
|
plan_id = first_item.price.id
|
||||||
|
|
||||||
# Handle period dates for trial vs active subscriptions
|
# Handle period dates for trial vs active subscriptions
|
||||||
# During trial: current_period_* fields are only in subscription items, not root
|
# During trial: current_period_* fields are only in subscription items, not root
|
||||||
# After trial: current_period_* fields are at root level
|
# After trial: current_period_* fields are at root level
|
||||||
if stripe_subscription.status == 'trialing' and stripe_subscription.items and stripe_subscription.items.data:
|
if stripe_subscription.status == 'trialing' and hasattr(stripe_subscription, 'items') and stripe_subscription.items:
|
||||||
# For trial subscriptions, get period from first subscription item
|
items_list = stripe_subscription.items.data if hasattr(stripe_subscription.items, 'data') else stripe_subscription.items
|
||||||
first_item = stripe_subscription.items.data[0]
|
if items_list and len(items_list) > 0:
|
||||||
current_period_start = first_item.current_period_start
|
# For trial subscriptions, get period from first subscription item
|
||||||
current_period_end = first_item.current_period_end
|
first_item = items_list[0] if isinstance(items_list, list) else items_list
|
||||||
|
current_period_start = first_item.current_period_start
|
||||||
|
current_period_end = first_item.current_period_end
|
||||||
else:
|
else:
|
||||||
# For active subscriptions, get period from root level
|
# For active subscriptions, get period from root level
|
||||||
current_period_start = stripe_subscription.current_period_start
|
current_period_start = stripe_subscription.current_period_start
|
||||||
@@ -381,9 +651,10 @@ class StripeProvider(PaymentProvider):
|
|||||||
current_subscription = stripe.Subscription.retrieve(subscription_id)
|
current_subscription = stripe.Subscription.retrieve(subscription_id)
|
||||||
|
|
||||||
# Build update parameters
|
# Build update parameters
|
||||||
|
items_list = current_subscription.items.data if hasattr(current_subscription.items, 'data') else current_subscription.items
|
||||||
update_params = {
|
update_params = {
|
||||||
'items': [{
|
'items': [{
|
||||||
'id': current_subscription.items.data[0].id,
|
'id': items_list[0].id if isinstance(items_list, list) else items_list.id,
|
||||||
'price': new_price_id,
|
'price': new_price_id,
|
||||||
}],
|
}],
|
||||||
'proration_behavior': proration_behavior,
|
'proration_behavior': proration_behavior,
|
||||||
@@ -411,12 +682,17 @@ class StripeProvider(PaymentProvider):
|
|||||||
|
|
||||||
# Get the actual plan ID from the subscription items
|
# Get the actual plan ID from the subscription items
|
||||||
plan_id = new_price_id
|
plan_id = new_price_id
|
||||||
if updated_subscription.items and updated_subscription.items.data:
|
if hasattr(updated_subscription, 'items') and updated_subscription.items:
|
||||||
plan_id = updated_subscription.items.data[0].price.id
|
items_list = updated_subscription.items.data if hasattr(updated_subscription.items, 'data') else updated_subscription.items
|
||||||
|
if items_list and len(items_list) > 0:
|
||||||
|
first_item = items_list[0] if isinstance(items_list, list) else items_list
|
||||||
|
plan_id = first_item.price.id
|
||||||
|
|
||||||
# Handle period dates for trial vs active subscriptions
|
# Handle period dates for trial vs active subscriptions
|
||||||
if updated_subscription.status == 'trialing' and updated_subscription.items and updated_subscription.items.data:
|
if updated_subscription.status == 'trialing' and hasattr(updated_subscription, 'items') and updated_subscription.items:
|
||||||
first_item = updated_subscription.items.data[0]
|
items_list = updated_subscription.items.data if hasattr(updated_subscription.items, 'data') else updated_subscription.items
|
||||||
|
if items_list and len(items_list) > 0:
|
||||||
|
first_item = items_list[0] if isinstance(items_list, list) else items_list
|
||||||
current_period_start = first_item.current_period_start
|
current_period_start = first_item.current_period_start
|
||||||
current_period_end = first_item.current_period_end
|
current_period_end = first_item.current_period_end
|
||||||
else:
|
else:
|
||||||
@@ -463,10 +739,12 @@ class StripeProvider(PaymentProvider):
|
|||||||
logger.info("Calculating proration for subscription change",
|
logger.info("Calculating proration for subscription change",
|
||||||
subscription_id=subscription_id,
|
subscription_id=subscription_id,
|
||||||
new_price_id=new_price_id)
|
new_price_id=new_price_id)
|
||||||
|
|
||||||
# Get current subscription
|
# Get current subscription
|
||||||
current_subscription = stripe.Subscription.retrieve(subscription_id)
|
current_subscription = stripe.Subscription.retrieve(subscription_id)
|
||||||
current_price_id = current_subscription.items.data[0].price.id
|
items_list = current_subscription.items.data if hasattr(current_subscription.items, 'data') else current_subscription.items
|
||||||
|
first_item = items_list[0] if isinstance(items_list, list) else items_list
|
||||||
|
current_price_id = first_item.price.id
|
||||||
|
|
||||||
# Get current and new prices
|
# Get current and new prices
|
||||||
current_price = stripe.Price.retrieve(current_price_id)
|
current_price = stripe.Price.retrieve(current_price_id)
|
||||||
@@ -560,10 +838,12 @@ class StripeProvider(PaymentProvider):
|
|||||||
logger.info("Changing billing cycle for subscription",
|
logger.info("Changing billing cycle for subscription",
|
||||||
subscription_id=subscription_id,
|
subscription_id=subscription_id,
|
||||||
new_billing_cycle=new_billing_cycle)
|
new_billing_cycle=new_billing_cycle)
|
||||||
|
|
||||||
# Get current subscription
|
# Get current subscription
|
||||||
current_subscription = stripe.Subscription.retrieve(subscription_id)
|
current_subscription = stripe.Subscription.retrieve(subscription_id)
|
||||||
current_price_id = current_subscription.items.data[0].price.id
|
items_list = current_subscription.items.data if hasattr(current_subscription.items, 'data') else current_subscription.items
|
||||||
|
first_item = items_list[0] if isinstance(items_list, list) else items_list
|
||||||
|
current_price_id = first_item.price.id
|
||||||
|
|
||||||
# Get current price to determine the plan
|
# Get current price to determine the plan
|
||||||
current_price = stripe.Price.retrieve(current_price_id)
|
current_price = stripe.Price.retrieve(current_price_id)
|
||||||
@@ -643,7 +923,7 @@ class StripeProvider(PaymentProvider):
|
|||||||
payment_method=payment_method_id,
|
payment_method=payment_method_id,
|
||||||
confirm=True
|
confirm=True
|
||||||
)
|
)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
'id': payment_intent.id,
|
'id': payment_intent.id,
|
||||||
'client_secret': payment_intent.client_secret,
|
'client_secret': payment_intent.client_secret,
|
||||||
@@ -652,3 +932,90 @@ class StripeProvider(PaymentProvider):
|
|||||||
except stripe.error.StripeError as e:
|
except stripe.error.StripeError as e:
|
||||||
logger.error("Failed to create Stripe payment intent", error=str(e))
|
logger.error("Failed to create Stripe payment intent", error=str(e))
|
||||||
raise e
|
raise e
|
||||||
|
|
||||||
|
async def get_customer_payment_method(self, customer_id: str) -> Optional[PaymentMethod]:
|
||||||
|
"""
|
||||||
|
Get the default payment method for a customer from Stripe
|
||||||
|
|
||||||
|
Args:
|
||||||
|
customer_id: Stripe customer ID
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
PaymentMethod object or None if no payment method exists
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
logger.info("Retrieving customer payment method", customer_id=customer_id)
|
||||||
|
|
||||||
|
# Retrieve the customer to get default payment method
|
||||||
|
stripe_customer = stripe.Customer.retrieve(customer_id)
|
||||||
|
|
||||||
|
# Get the default payment method ID
|
||||||
|
default_payment_method_id = None
|
||||||
|
if stripe_customer.invoice_settings and stripe_customer.invoice_settings.default_payment_method:
|
||||||
|
default_payment_method_id = stripe_customer.invoice_settings.default_payment_method
|
||||||
|
elif stripe_customer.default_source:
|
||||||
|
default_payment_method_id = stripe_customer.default_source
|
||||||
|
|
||||||
|
if not default_payment_method_id:
|
||||||
|
logger.info("No default payment method found for customer", customer_id=customer_id)
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Retrieve the payment method details
|
||||||
|
stripe_payment_method = stripe.PaymentMethod.retrieve(default_payment_method_id)
|
||||||
|
|
||||||
|
# Extract payment method details based on type
|
||||||
|
payment_method_details = getattr(stripe_payment_method, stripe_payment_method.type, {})
|
||||||
|
|
||||||
|
logger.info("Customer payment method retrieved successfully",
|
||||||
|
customer_id=customer_id,
|
||||||
|
payment_method_id=stripe_payment_method.id,
|
||||||
|
payment_method_type=stripe_payment_method.type)
|
||||||
|
|
||||||
|
return PaymentMethod(
|
||||||
|
id=stripe_payment_method.id,
|
||||||
|
type=stripe_payment_method.type,
|
||||||
|
brand=payment_method_details.get('brand') if isinstance(payment_method_details, dict) else getattr(payment_method_details, 'brand', None),
|
||||||
|
last4=payment_method_details.get('last4') if isinstance(payment_method_details, dict) else getattr(payment_method_details, 'last4', None),
|
||||||
|
exp_month=payment_method_details.get('exp_month') if isinstance(payment_method_details, dict) else getattr(payment_method_details, 'exp_month', None),
|
||||||
|
exp_year=payment_method_details.get('exp_year') if isinstance(payment_method_details, dict) else getattr(payment_method_details, 'exp_year', None),
|
||||||
|
)
|
||||||
|
|
||||||
|
except stripe.error.StripeError as e:
|
||||||
|
logger.error("Failed to retrieve customer payment method",
|
||||||
|
error=str(e),
|
||||||
|
customer_id=customer_id)
|
||||||
|
raise e
|
||||||
|
|
||||||
|
async def get_setup_intent(self, setup_intent_id: str) -> stripe.SetupIntent:
|
||||||
|
"""
|
||||||
|
Retrieve a SetupIntent from Stripe
|
||||||
|
|
||||||
|
Args:
|
||||||
|
setup_intent_id: SetupIntent ID to retrieve
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
stripe.SetupIntent object
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
stripe.error.StripeError: If retrieval fails
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
logger.info("Retrieving SetupIntent from Stripe",
|
||||||
|
setup_intent_id=setup_intent_id)
|
||||||
|
|
||||||
|
# Retrieve SetupIntent from Stripe
|
||||||
|
setup_intent = stripe.SetupIntent.retrieve(setup_intent_id)
|
||||||
|
|
||||||
|
logger.info("SetupIntent retrieved successfully",
|
||||||
|
setup_intent_id=setup_intent.id,
|
||||||
|
status=setup_intent.status,
|
||||||
|
customer_id=setup_intent.customer)
|
||||||
|
|
||||||
|
return setup_intent
|
||||||
|
|
||||||
|
except stripe.error.StripeError as e:
|
||||||
|
logger.error("Failed to retrieve SetupIntent from Stripe",
|
||||||
|
error=str(e),
|
||||||
|
setup_intent_id=setup_intent_id,
|
||||||
|
error_type=type(e).__name__)
|
||||||
|
raise e
|
||||||
|
|||||||
@@ -622,6 +622,184 @@ class TenantServiceClient(BaseServiceClient):
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
async def create_payment_customer(
|
||||||
|
self,
|
||||||
|
user_data: Dict[str, Any],
|
||||||
|
payment_method_id: Optional[str] = None
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Create payment customer (supports pre-user-creation flow)
|
||||||
|
|
||||||
|
This method creates a payment customer without requiring a user_id,
|
||||||
|
supporting the secure architecture where users are only created after
|
||||||
|
payment verification.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user_data: User data (email, full_name, etc.)
|
||||||
|
payment_method_id: Optional payment method ID
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary with payment customer creation result
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
logger.info("Creating payment customer via tenant service",
|
||||||
|
email=user_data.get('email'),
|
||||||
|
payment_method_id=payment_method_id)
|
||||||
|
|
||||||
|
# Call tenant service endpoint
|
||||||
|
result = await self.post(
|
||||||
|
"/payment-customers/create",
|
||||||
|
{
|
||||||
|
"user_data": user_data,
|
||||||
|
"payment_method_id": payment_method_id
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
if result and result.get("success"):
|
||||||
|
logger.info("Payment customer created successfully via tenant service",
|
||||||
|
email=user_data.get('email'),
|
||||||
|
payment_customer_id=result.get('payment_customer_id'))
|
||||||
|
return result
|
||||||
|
else:
|
||||||
|
logger.error("Payment customer creation failed via tenant service",
|
||||||
|
email=user_data.get('email'),
|
||||||
|
error=result.get('detail') if result else 'No detail provided')
|
||||||
|
raise Exception("Payment customer creation failed: " +
|
||||||
|
(result.get('detail') if result else 'Unknown error'))
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("Failed to create payment customer via tenant service",
|
||||||
|
email=user_data.get('email'),
|
||||||
|
error=str(e))
|
||||||
|
raise
|
||||||
|
|
||||||
|
async def create_registration_payment_setup(
|
||||||
|
self,
|
||||||
|
user_data: Dict[str, Any]
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Create registration payment setup via tenant service orchestration
|
||||||
|
|
||||||
|
This method calls the tenant service's orchestration endpoint to create
|
||||||
|
payment customer and SetupIntent in a coordinated workflow.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user_data: User data including email, full_name, payment_method_id, etc.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary with payment setup results including SetupIntent if required
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
logger.info("Creating registration payment setup via tenant service orchestration",
|
||||||
|
email=user_data.get('email'),
|
||||||
|
payment_method_id=user_data.get('payment_method_id'))
|
||||||
|
|
||||||
|
# Call tenant service orchestration endpoint
|
||||||
|
result = await self.post(
|
||||||
|
"/payment-customers/create",
|
||||||
|
user_data
|
||||||
|
)
|
||||||
|
|
||||||
|
if result and result.get("success"):
|
||||||
|
logger.info("Registration payment setup completed via tenant service orchestration",
|
||||||
|
email=user_data.get('email'),
|
||||||
|
requires_action=result.get('requires_action'))
|
||||||
|
return result
|
||||||
|
else:
|
||||||
|
logger.error("Registration payment setup failed via tenant service orchestration",
|
||||||
|
email=user_data.get('email'),
|
||||||
|
error=result.get('detail') if result else 'No detail provided')
|
||||||
|
raise Exception("Registration payment setup failed: " +
|
||||||
|
(result.get('detail') if result else 'Unknown error'))
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("Failed to create registration payment setup via tenant service orchestration",
|
||||||
|
email=user_data.get('email'),
|
||||||
|
error=str(e))
|
||||||
|
raise
|
||||||
|
|
||||||
|
async def verify_setup_intent_for_registration(
|
||||||
|
self,
|
||||||
|
setup_intent_id: str
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Verify SetupIntent status via tenant service orchestration
|
||||||
|
|
||||||
|
This method calls the tenant service's orchestration endpoint to verify
|
||||||
|
SetupIntent status before proceeding with user creation.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
setup_intent_id: SetupIntent ID to verify
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary with SetupIntent verification result
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
logger.info("Verifying SetupIntent via tenant service orchestration",
|
||||||
|
setup_intent_id=setup_intent_id)
|
||||||
|
|
||||||
|
# Call tenant service orchestration endpoint
|
||||||
|
result = await self.get(
|
||||||
|
f"/setup-intents/{setup_intent_id}/verify"
|
||||||
|
)
|
||||||
|
|
||||||
|
if result:
|
||||||
|
logger.info("SetupIntent verification result from tenant service orchestration",
|
||||||
|
setup_intent_id=setup_intent_id,
|
||||||
|
status=result.get('status'))
|
||||||
|
return result
|
||||||
|
else:
|
||||||
|
logger.error("SetupIntent verification failed via tenant service orchestration",
|
||||||
|
setup_intent_id=setup_intent_id,
|
||||||
|
error='No result returned')
|
||||||
|
raise Exception("SetupIntent verification failed: No result returned")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("Failed to verify SetupIntent via tenant service orchestration",
|
||||||
|
setup_intent_id=setup_intent_id,
|
||||||
|
error=str(e))
|
||||||
|
raise
|
||||||
|
|
||||||
|
async def verify_setup_intent(
|
||||||
|
self,
|
||||||
|
setup_intent_id: str
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Verify SetupIntent status with payment provider
|
||||||
|
|
||||||
|
Args:
|
||||||
|
setup_intent_id: SetupIntent ID to verify
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary with SetupIntent verification result
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
logger.info("Verifying SetupIntent via tenant service",
|
||||||
|
setup_intent_id=setup_intent_id)
|
||||||
|
|
||||||
|
# Call tenant service endpoint
|
||||||
|
result = await self.get(
|
||||||
|
f"/setup-intents/{setup_intent_id}/verify"
|
||||||
|
)
|
||||||
|
|
||||||
|
if result:
|
||||||
|
logger.info("SetupIntent verification result from tenant service",
|
||||||
|
setup_intent_id=setup_intent_id,
|
||||||
|
status=result.get('status'))
|
||||||
|
return result
|
||||||
|
else:
|
||||||
|
logger.error("SetupIntent verification failed via tenant service",
|
||||||
|
setup_intent_id=setup_intent_id,
|
||||||
|
error='No result returned')
|
||||||
|
raise Exception("SetupIntent verification failed: No result returned")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("Failed to verify SetupIntent via tenant service",
|
||||||
|
setup_intent_id=setup_intent_id,
|
||||||
|
error=str(e))
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
# Factory function for dependency injection
|
# Factory function for dependency injection
|
||||||
def create_tenant_client(config: BaseServiceSettings) -> TenantServiceClient:
|
def create_tenant_client(config: BaseServiceSettings) -> TenantServiceClient:
|
||||||
"""Create tenant service client instance"""
|
"""Create tenant service client instance"""
|
||||||
|
|||||||
Reference in New Issue
Block a user