Add subcription feature 2

This commit is contained in:
Urtzi Alfaro
2026-01-14 13:15:48 +01:00
parent 6ddf608d37
commit a4c3b7da3f
32 changed files with 4240 additions and 965 deletions

View File

@@ -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);
} }

View File

@@ -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
// ============================================================================ // ============================================================================

View File

@@ -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
} }
/** /**

View File

@@ -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">

View File

@@ -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 }));

View File

@@ -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,7 +142,110 @@ 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);
// Log 3DS requirement status
if (result.requires_action) {
console.log('3DS authentication required for payment method update', {
payment_method_id: paymentMethod.id,
setup_intent_id: result.payment_intent_id,
action_required: result.requires_action
});
}
if (result.success) { if (result.success) {
// Check if 3D Secure authentication is required
if (result.requires_action && result.client_secret) {
setAuthenticating(true);
setLoading(false);
setError(null);
console.log('Starting 3DS authentication process', {
client_secret: result.client_secret.substring(0, 20) + '...', // Log partial secret for security
payment_method_id: paymentMethod.id,
timestamp: new Date().toISOString()
});
try {
// Handle 3D Secure authentication
const { error: confirmError, setupIntent } = await stripe.confirmCardSetup(result.client_secret);
console.log('3DS authentication completed', {
setup_intent_status: setupIntent?.status,
error: confirmError ? confirmError.message : 'none',
timestamp: new Date().toISOString()
});
if (confirmError) {
// Handle specific 3D Secure error types
if (confirmError.type === 'card_error') {
setError(confirmError.message || 'Card authentication failed');
} else if (confirmError.type === 'validation_error') {
setError('Invalid authentication request');
} else if (confirmError.type === 'api_error') {
setError('Authentication service unavailable');
} else {
setError(confirmError.message || '3D Secure authentication failed');
}
setAuthenticating(false);
return;
}
// Check setup intent status after authentication
if (setupIntent && setupIntent.status === 'succeeded') {
setSuccess(true);
showToast.success('Payment method updated and authenticated successfully');
console.log('3DS authentication successful', {
payment_method_id: paymentMethod.id,
setup_intent_id: setupIntent.id,
setup_intent_status: setupIntent.status,
timestamp: new Date().toISOString()
});
// Notify parent component about the update
onPaymentMethodUpdated({
brand: result.brand,
last4: result.last4,
exp_month: result.exp_month,
exp_year: result.exp_year,
});
// Close modal after a brief delay to show success message
setTimeout(() => {
onClose();
}, 2000);
} else {
console.log('3DS authentication completed with non-success status', {
setup_intent_status: setupIntent?.status,
payment_method_id: paymentMethod.id,
timestamp: new Date().toISOString()
});
setError('3D Secure authentication completed but payment method not confirmed');
setLoading(false);
}
} catch (authError) {
console.error('Error during 3D Secure authentication:', authError);
// Enhanced error handling for 3DS failures
if (authError instanceof Error) {
if (authError.message.includes('canceled') || authError.message.includes('user closed')) {
setError('3D Secure authentication was canceled. You can try again with the same card.');
} else if (authError.message.includes('failed') || authError.message.includes('declined')) {
setError('3D Secure authentication failed. Please try again or use a different card.');
} else if (authError.message.includes('timeout') || authError.message.includes('network')) {
setError('Network error during 3D Secure authentication. Please check your connection and try again.');
} else {
setError(`3D Secure authentication error: ${authError.message}`);
}
} else {
setError('3D Secure authentication error. Please try again.');
}
setAuthenticating(false);
}
} else {
// No authentication required
setSuccess(true); setSuccess(true);
showToast.success(result.message); showToast.success(result.message);
@@ -157,9 +261,21 @@ export const PaymentMethodUpdateModal: React.FC<PaymentMethodUpdateModalProps> =
setTimeout(() => { setTimeout(() => {
onClose(); onClose();
}, 2000); }, 2000);
}
} else {
// Handle different payment intent statuses
if (result.payment_intent_status === 'requires_payment_method') {
setError('Your card was declined. Please try a different payment method.');
} else if (result.payment_intent_status === 'requires_action') {
setError('Authentication required but client secret missing. Please try again.');
} else if (result.payment_intent_status === 'processing') {
setError('Payment is processing. Please wait a few minutes and try again.');
} else if (result.payment_intent_status === 'canceled') {
setError('Payment was canceled. Please try again.');
} else { } else {
setError(result.message || 'Failed to update payment method'); setError(result.message || 'Failed to update payment method');
} }
}
} catch (err) { } catch (err) {
console.error('Error updating payment method:', err); console.error('Error updating payment method:', 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>

View File

@@ -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",

View File

@@ -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": [
{ {
"title": "Recepción de Pedidos", "type": "PURCHASE (Entrada por Compra)",
"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": "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."
}, },
{ {
"title": "Actualización Automática", "type": "PRODUCTION_USE (Salida por Producción)",
"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": "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)."
} }
], ]
"compliance": [ },
"inventoryValuation": {
"title": "Valoración de Inventario y Contabilidad",
"methods": [
{ {
"name": "HACCP", "method": "Weighted Average Cost (WAC) - Método Principal",
"description": "Sistema cumple con normas HACCP de seguridad alimentaria. Trazabilidad completa de lotes, temperaturas de almacenaje" "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)."
}, },
{ {
"name": "Auditoría", "method": "FIFO Costing (Opcional para Reportes)",
"description": "Historial completo de movimientos de stock para auditorías. Exportable a PDF/Excel" "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": [ "metrics": [
"Valor total de inventario (€)", {
"Rotación de inventario (cuántas veces al año renuevas stock)", "metric": "Valor Total de Inventario",
"Desperdicio por caducidad (kg y € al mes)", "calculation": "Σ(current_quantity × average_cost) para todos los stocks activos (is_available=true)",
"Ingredientes con stock crítico" "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": [
{
"practice": "Configurar Umbrales Realistas",
"description": "Fórmula reorder_point: reorder_point = (daily_usage × supplier_lead_time_days) × safety_factor. Ejemplo: Harina T-55: daily_usage = 50 kg/día (promedio últimos 30 días), lead_time = 2 días (tu proveedor entrega en 2 días), safety_factor = 1.2 (20% buffer para variabilidad), reorder_point = 50 × 2 × 1.2 = 120 kg. Interpretación: Cuando stock baje de 120 kg, hacer pedido. Con consumo de 50 kg/día, tienes 2.4 días de stock (tiempo suficiente para recibir pedido en 2 días). Ajustar safety_factor: 1.1-1.2 (10-20%) para ingredientes estables y proveedores fiables, 1.3-1.5 (30-50%) para ingredientes volátiles o proveedores con lead time variable, 1.5-2.0 (50-100%) para ingredientes críticos sin sustituto."
},
{
"practice": "Inventario Físico Regular",
"description": "Frecuencia recomendada: Inventario completo: Trimestral (cada 3 meses) para todos los ingredientes, Inventario parcial (ABC analysis): Categoría A (alto valor, 20% ingredientes, 80% valor): Mensual, Categoría B (valor medio, 30% ingredientes, 15% valor): Bimestral, Categoría C (bajo valor, 50% ingredientes, 5% valor): Trimestral. Proceso: 1) Generar reporte de stock teórico: GET /inventory/stock/summary, 2) Contar físicamente (mejor fin de mes cuando stock es bajo), 3) Comparar stock_teorico vs stock_fisico, 4) Si discrepancia > 5%: Investigar (pérdida, theft, error de registro), Ajustar: PUT /stock/{id} con adjustment_reason='physical_inventory', 5) Analizar tendencias de discrepancias (si sistemáticas → problema de proceso). Beneficios: Detecta mermas ocultas (evaporación, derrames, robo), Corrige errores acumulados de registro, Mantiene confianza en datos del sistema, Cumple normativa contable (inventarios auditables)."
},
{
"practice": "Rotación FIFO Disciplinada",
"description": "Aunque sistema consume FIFO automáticamente en producción, el almacenaje físico debe facilitar FIFO: Organización de almacén: Lotes nuevos atrás, antiguos adelante (operadores toman de adelante naturalmente), Etiquetado claro: Fecha recepción + Fecha caducidad visible en todos los lotes, Zonas separadas por urgencia: Zona A (expira 0-7 días): Usar PRIMERO, prominente, Zona B (expira 8-30 días): Usar segundo, Zona C (>30 días): Stock de reserva. Auditoría física semanal: Revisar que lotes adelante son realmente los más antiguos (comparar etiquetas vs ubicación), Si encuentras lote antiguo atrás → mover a adelante. Capacitación personal: Entrenar equipo en FIFO importance, Consecuencias de no seguir FIFO (caducidades, waste, coste). Métricas de compliance: Waste_by_expiration: Si >60% del waste es por caducidad → posible problema de FIFO físico (vs otros waste reasons como damaged, overproduction)."
},
{
"practice": "Monitoreo de Caducidades Proactivo",
"description": "No esperar alertas del sistema, revisar proactivamente: Daily check (5 min cada mañana): Dashboard → Expiring Stock Widget → Items expiring next 3 days, Planificar uso inmediato (incluir en producción del día o día siguiente). Weekly review (15 min cada lunes): GET /stock/expiring?days_ahead=14 → items expiring próximas 2 semanas, Priorizar en plan de producción semanal, Considerar promociones (vender productos con ingredientes próximos a caducar a precio reducido para mover stock rápido). Monthly analysis (30 min fin de mes): Waste report → % waste por expired, Identificar ingredientes con alta tasa de caducidad (compras excesivas? shelf life corto?), Ajustar reorder_quantity o negociar compras más frecuentes/pequeñas con proveedor. Automation: Configurar notificaciones automáticas: Email diario con items expiring <3 days (criticales), Email semanal con items expiring <7 days (planificar)."
},
{
"practice": "Optimización de Niveles de Stock",
"description": "Revisar y ajustar trimestralmente: Analizar consumo real: GET /inventory/analytics?metric=consumption&period=90d → consumption_daily_avg por ingrediente, Comparar con reorder_point actual: Si consumo aumentó: Subir reorder_point (evitar roturas de stock), Si consumo bajó: Bajar reorder_point (reducir capital inmovilizado). Optimizar reorder_quantity: Economic Order Quantity (EOQ) formula: EOQ = √[(2 × annual_demand × order_cost) / holding_cost_per_unit], Ejemplo: Harina demand anual: 18,000 kg, Order cost (logística, admin): 25€/pedido, Holding cost (almacenaje, capital): 0.50€/kg/año, EOQ = √[(2×18000×25) / 0.50] = √1,800,000 = 1,342 kg por pedido. Balancear vs supplier constraints: Si EOQ sugiere 1,342 kg pero proveedor vende en sacos de 25 kg → redondear a 1,350 kg (54 sacos), Si proveedor ofrece descuento por volumen >2,000 kg → evaluar trade-off (ahorro compra vs coste almacenaje extra). Resultado: Minimize total cost = purchase cost + order cost + holding cost."
}
],
"troubleshooting": [
{
"problem": "Discrepancias persistentes entre stock teórico y físico",
"solutions": [
"Auditoría de movimientos: GET /stock/movements?ingredient_id=X&date_range=last_30d → revisar TODOS los movimientos, buscar: Movimientos sin reference_number (posibles errores manuales), Ajustes frecuentes del mismo user_id (posible error sistemático o fraude), Consumos que no matchean con production batches completados",
"Verificar proceso de registro: ¿Personal registra TODAS las salidas (production, waste, transfers)?, ¿Hay consumos ad-hoc no registrados (chef toma ingredientes para pruebas)?, Implementar: Control de acceso físico a almacén (solo personal autorizado), Registro obligatorio de salidas (formulario, incluso para pequeñas cantidades)",
"Investigar pérdidas naturales: Ingredientes con evaporación (líquidos en contenedores no herméticos), Merma por manipulación (bolsas rotas, derrames), Condensación/humedad (cambios de peso en harinas), Solución: Registrar allowance de merma normal (ej: 1-2% en harinas), Comparar discrepancia vs allowance",
"Automatizar cuando sea posible: Si discrepancias en producción → integrar balanzas digitales con sistema (auto-registro de consumos), Si discrepancias en recepción → escaneo de códigos de barras al recibir (evita errores de entrada manual)"
]
},
{
"problem": "Alertas de stock bajo muy frecuentes (rotura de stock continua)",
"solutions": [
"Recalcular reorder_point: Fórmula actual: reorder_point = daily_usage × lead_time × 1.2, Revisar daily_usage: GET /analytics?metric=consumption&period=30d → usar promedio más reciente (últimos 30 días, no 90+ días si consumo cambió), Revisar lead_time: ¿Proveedor entrega realmente en 2 días o suele tardar 3-4? Usar lead_time_real máximo observado, Aumentar safety_factor de 1.2 a 1.3-1.5 si: Proveedor poco fiable (retrasos frecuentes), Consumo muy variable (CV >0.5), Ingrediente crítico sin sustituto",
"Optimizar reorder_quantity: Si haces pedidos pequeños → aumentar reorder_quantity para reducir frecuencia de pedidos, Negociar entregas más frecuentes con proveedor (semanal vs quincenal) → permite reducir reorder_point (menos stock de seguridad necesario)",
"Automatizar pedidos: Configurar auto-purchase orders para ingredientes críticos estables, Cuando stock < reorder_point → sistema crea PO automáticamente, Proveedor recibe email con pedido, Confirma delivery date, Manager solo aprueba (no crea manualmente)",
"Alertas tempranas: Configurar alert cuando stock = 1.5 × reorder_point (50% antes del punto crítico) → da más margen para actuar"
]
},
{
"problem": "Waste alto por caducidades (>10% del total)",
"solutions": [
"Analizar causas raíz: GET /analytics/waste?waste_reason=expired&period=90d, Breakdown por ingrediente: ¿Pocos ingredientes causan mayoría del waste? (Pareto 80/20), Top offenders → investigar específicamente cada uno",
"Soluciones por causa: Compras excesivas: Reducir reorder_quantity (comprar más frecuente, cantidades menores), Shelf life corto: Negociar con proveedor entregas más frecuentes (semanal vs quincenal), Productos de lento movimiento reducen frescura de rápidos, Consumo irregular (picos y valles): Usar forecasting para ajustar compras a demanda real, Evitar compras 'por si acaso' sin forecast que lo justifique, FIFO físico no respetado: Auditar almacén físico (lotes antiguos escondidos atrás?), Re-entrenar personal en FIFO importance",
"Tácticas de reducción: Promociones pre-expiration: Productos que caducan en 3-5 días → vender con descuento (mejor margen reducido que waste 100%), Donar antes de caducar: Contactar bancos de alimentos (aceptan productos 1-2 días antes de expiration), Tax benefits + RSC + zero waste, Transformar productos: Ingredientes próximos a caducar → usar en productos con shelf life corto (pan del día, producción para consumo inmediato)",
"Monitoreo continuo: Weekly waste review meeting (15 min), Meta: Reducir waste_rate 1% cada mes hasta alcanzar <5% objetivo"
]
}
],
"advancedFeatures": [
{
"feature": "AI-Powered Inventory Optimization (Roadmap)",
"description": "Machine Learning analizará patrones históricos para: Predecir consumo futuro por ingrediente (más preciso que promedios simples), Recomendar ajustes dinámicos de reorder_point según estacionalidad (verano vs invierno, festivos), Detectar anomalías (consumo inusual → posible theft, error de registro, nuevo patrón de demanda), Sugerir consolidación de proveedores (ingredientes similares de proveedores diferentes → negociar mejor precio con uno solo)."
},
{
"feature": "IoT Sensors Integration",
"description": "Integración con sensores de almacén: Temperature & Humidity Sensors: Monitoreo 24/7 de cámaras frías, almacenes, Alertas automáticas si temperatura fuera de rango (food safety), Weight Sensors en estanterías: Tracking automático de stock level sin registro manual, Detección de discrepancias en tiempo real (stock_sensor vs stock_sistema), RFID Tags en lotes: Escaneo automático al mover lotes (recepción, uso, transferencia), Trazabilidad automática sin intervención humana. Status: En roadmap, pilotos con clientes enterprise."
},
{
"feature": "Multi-Location Inventory (Enterprise)",
"description": "Para empresas con obrador central + sucursales: Stock unificado a nivel tenant con location dimension, Transfers automáticos: Obrador produce → auto-transfer a sucursales según forecast por sucursal, Dashboard consolidado: Stock total cross-location, Stock por location (drill-down), Transfer optimization: Sistema sugiere qué sucursal necesita reposición urgente, cuánto transferir desde obrador/otras sucursales. Disponible en plan Enterprise."
}
],
"conclusion": "El sistema de inventario es la columna vertebral operativa de BakeWise. Invierte tiempo en: 1) Configuración inicial correcta (umbrales realistas de reorder_point basados en consumo real + lead time), 2) Disciplina de registro (TODAS las entradas y salidas, sin excepciones), 3) Inventario físico regular (trimestral mínimo, mensual ideal para ingredientes críticos), 4) Análisis continuo (revisar waste, discrepancias, niveles de stock semanalmente). Con inventario bien gestionado, lograrás: Waste <5% (vs 15-25% sin sistema), Roturas de stock <2% (vs 10-20% sin sistema), Capital inmovilizado -30% (stock óptimo, no excesivo ni insuficiente), Compliance HACCP 100% (trazabilidad completa para auditorías). El ROI típico del módulo de inventario es 300-500% en el primer año solo por reducción de waste."
} }
}, },
"posIntegration": { "posIntegration": {

View 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>
);
};

View File

@@ -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,

View File

@@ -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

View File

@@ -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,40 +161,92 @@ 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)
subscription_result = await auth_service.create_subscription_via_tenant_service( if subscription_id:
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,
@@ -188,13 +255,42 @@ async def register_with_subscription(
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)
else:
logger.info("No subscription data provided, skipping subscription creation",
user_id=user_id) user_id=user_id)
# Record successful registration
if metrics:
metrics.increment_counter("enhanced_registration_with_subscription_total",
labels={"status": "success"})
logger.info("Registration with subscription completed successfully using secure architecture",
user_id=user_id,
email=user_data.email,
subscription_id=result.subscription_id)
return result
except HTTPException:
raise
except Exception as e:
if metrics:
error_type = "validation_error" if "validation" in str(e).lower() else "conflict" if "conflict" in str(e).lower() else "failed"
metrics.increment_counter("enhanced_registration_with_subscription_total",
labels={"status": error_type})
logger.error("Registration with subscription system error using secure architecture",
email=user_data.email,
error=str(e),
exc_info=True)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Registration with subscription failed: " + str(e)
)
# Record successful registration # Record successful registration
if metrics: if metrics:
metrics.increment_counter("enhanced_registration_with_subscription_total", labels={"status": "success"}) metrics.increment_counter("enhanced_registration_with_subscription_total", labels={"status": "success"})
@@ -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"""

View File

@@ -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(

View File

@@ -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"
} }
} }

View File

@@ -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,

View File

@@ -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,9 +28,14 @@ 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:
if session:
# Use provided session (for direct session injection)
user_repo = UserRepository(User, session)
else:
# Use database manager to get session
async with self.database_manager.get_session() as session: async with self.database_manager.get_session() as session:
user_repo = UserRepository(User, session) user_repo = UserRepository(User, session)

View File

@@ -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

View File

@@ -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,

View File

@@ -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:
@@ -976,15 +976,29 @@ async def create_subscription_for_registration(
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"),
@@ -1009,24 +1153,15 @@ 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
) )
@@ -1035,15 +1170,7 @@ async def update_payment_method(
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",
@@ -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)
)

View File

@@ -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"),
@@ -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:

View File

@@ -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)

View File

@@ -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"]:

View File

@@ -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:

View File

@@ -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
result = await retry_with_backoff(
lambda: self.payment_provider.create_subscription(
customer_id, customer_id,
stripe_price_id, stripe_price_id,
payment_method_id, payment_method_id,
trial_period_days 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
result = await retry_with_backoff(
lambda: self.payment_provider.update_subscription(
subscription_id, subscription_id,
new_price_id, new_price_id,
proration_behavior, proration_behavior,
billing_cycle_anchor, billing_cycle_anchor,
payment_behavior, payment_behavior,
immediate_change 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
result = await retry_with_backoff(
lambda: self.payment_provider.calculate_proration(
subscription_id, subscription_id,
new_price_id, new_price_id,
proration_behavior 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
result = await retry_with_backoff(
lambda: self.payment_provider.change_billing_cycle(
subscription_id, subscription_id,
new_billing_cycle, new_billing_cycle,
proration_behavior 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],
@@ -441,3 +615,125 @@ class PaymentService:
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

View File

@@ -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

View File

@@ -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)

View File

@@ -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,

View File

@@ -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')

View File

@@ -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

View File

@@ -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,9 +67,12 @@ 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()}"
@@ -107,6 +115,64 @@ class StripeProvider(PaymentProvider):
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,
@@ -124,11 +190,11 @@ class StripeProvider(PaymentProvider):
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",
@@ -137,7 +203,6 @@ class StripeProvider(PaymentProvider):
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,7 +427,7 @@ 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
@@ -208,14 +436,49 @@ class StripeProvider(PaymentProvider):
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,8 +516,10 @@ 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
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:
@@ -316,15 +581,20 @@ 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:
items_list = stripe_subscription.items.data if hasattr(stripe_subscription.items, 'data') else stripe_subscription.items
if items_list and len(items_list) > 0:
# For trial subscriptions, get period from first subscription item # For trial subscriptions, get period from first subscription item
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
else: else:
@@ -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:
@@ -466,7 +742,9 @@ class StripeProvider(PaymentProvider):
# 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)
@@ -563,7 +841,9 @@ class StripeProvider(PaymentProvider):
# 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)
@@ -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

View File

@@ -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"""