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);
}
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> {
return apiClient.post<TokenResponse>(`${this.baseUrl}/login`, loginData);
}

View File

@@ -402,6 +402,26 @@ export class SubscriptionService {
return apiClient.get(`/subscriptions/${tenantId}/invoices`);
}
/**
* Get the current payment method for a subscription
*/
async getCurrentPaymentMethod(
tenantId: string
): Promise<{
brand: string;
last4: string;
exp_month?: number;
exp_year?: number;
} | null> {
try {
const response = await apiClient.get(`/subscriptions/${tenantId}/payment-method`);
return response;
} catch (error) {
console.error('Failed to get current payment method:', error);
return null;
}
}
/**
* Update the default payment method for a subscription
*/
@@ -416,10 +436,55 @@ export class SubscriptionService {
last4: string;
exp_month?: number;
exp_year?: number;
requires_action?: boolean;
client_secret?: string;
payment_intent_status?: string;
}> {
return apiClient.post(`/subscriptions/${tenantId}/update-payment-method?payment_method_id=${paymentMethodId}`, {});
}
/**
* Complete subscription creation after SetupIntent confirmation
*
* This method is called after the frontend successfully confirms a SetupIntent
* (with or without 3DS authentication). It verifies the SetupIntent and creates
* the subscription with the verified payment method.
*
* @param setupIntentId - The SetupIntent ID that was confirmed by Stripe
* @param subscriptionData - Data needed to complete subscription creation
* @returns Promise with subscription creation result
*/
async completeSubscriptionAfterSetupIntent(
setupIntentId: string,
subscriptionData: {
customer_id: string;
plan_id: string;
payment_method_id: string;
trial_period_days?: number;
user_id: string;
billing_interval: string;
}
): Promise<{
success: boolean;
message: string;
data: {
subscription_id: string;
customer_id: string;
status: string;
plan: string;
billing_cycle: string;
trial_period_days?: number;
current_period_end: string;
user_id: string;
setup_intent_id: string;
};
}> {
return apiClient.post('/subscriptions/complete-after-setup-intent', {
setup_intent_id: setupIntentId,
...subscriptionData
});
}
// ============================================================================
// NEW METHODS - Usage Forecasting & Predictive Analytics
// ============================================================================

View File

@@ -75,6 +75,18 @@ export interface UserRegistration {
*/
export interface UserRegistrationWithSubscriptionResponse extends TokenResponse {
subscription_id?: string | null; // ID of the created subscription (returned if subscription was created during registration)
requires_action?: boolean | null; // Whether 3DS/SetupIntent authentication is required
action_type?: string | null; // Type of action required (e.g., 'setup_intent_confirmation')
client_secret?: string | null; // Client secret for SetupIntent/PaymentIntent authentication
payment_intent_id?: string | null; // Payment intent ID (deprecated, use setup_intent_id)
setup_intent_id?: string | null; // SetupIntent ID for 3DS authentication
customer_id?: string | null; // Stripe customer ID (needed for completion)
plan_id?: string | null; // Plan ID (needed for completion)
payment_method_id?: string | null; // Payment method ID (needed for completion)
trial_period_days?: number | null; // Trial period days (needed for completion)
user_id?: string | null; // User ID (needed for completion)
billing_interval?: string | null; // Billing interval (needed for completion)
message?: string | null; // Message explaining what needs to be done
}
/**

View File

@@ -70,6 +70,52 @@ const PaymentForm: React.FC<PaymentFormProps> = ({
return;
}
// Perform client-side validation before sending to Stripe
if (!billingDetails.name.trim()) {
setError('El nombre del titular es obligatorio');
onPaymentError('El nombre del titular es obligatorio');
setLoading(false);
return;
}
if (!billingDetails.email.trim()) {
setError('El correo electrónico es obligatorio');
onPaymentError('El correo electrónico es obligatorio');
setLoading(false);
return;
}
// Basic email validation
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(billingDetails.email)) {
setError('Por favor, ingrese un correo electrónico válido');
onPaymentError('Por favor, ingrese un correo electrónico válido');
setLoading(false);
return;
}
// Validate required address fields
if (!billingDetails.address.line1.trim()) {
setError('La dirección es obligatoria');
onPaymentError('La dirección es obligatoria');
setLoading(false);
return;
}
if (!billingDetails.address.city.trim()) {
setError('La ciudad es obligatoria');
onPaymentError('La ciudad es obligatoria');
setLoading(false);
return;
}
if (!billingDetails.address.postal_code.trim()) {
setError('El código postal es obligatorio');
onPaymentError('El código postal es obligatorio');
setLoading(false);
return;
}
setLoading(true);
setError(null);
@@ -126,6 +172,16 @@ const PaymentForm: React.FC<PaymentFormProps> = ({
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 (
<Card className={`p-6 ${className}`}>
<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 [showPassword, setShowPassword] = useState(false);
const [showConfirmPassword, setShowConfirmPassword] = useState(false);
const [loading, setLoading] = useState(false); // Local loading state for 3DS flow
// State for pending subscription data (needed for post-3DS completion)
const [pendingSubscriptionData, setPendingSubscriptionData] = useState<{
customer_id: string;
plan_id: string;
payment_method_id: string;
trial_period_days?: number;
user_id: string;
billing_interval: string;
setup_intent_id?: string;
} | null>(null);
const { register, registerWithSubscription } = useAuthActions();
const isLoading = useAuthLoading();
@@ -144,14 +156,76 @@ export const RegisterForm: React.FC<RegisterFormProps> = ({
// directly to backend via secure API calls.
// Clean up any old registration_progress data on mount (security fix)
// Also check for Stripe 3DS redirect parameters
useEffect(() => {
try {
localStorage.removeItem('registration_progress');
localStorage.removeItem('wizardState'); // Clean up wizard state too
} catch (err) {
console.error('Error cleaning up old localStorage data:', err);
// Prevent multiple executions of 3DS redirect handling
const hasProcessedRedirect = sessionStorage.getItem('3ds_redirect_processed');
if (hasProcessedRedirect) {
return;
}
}, []);
// Clean up old data
localStorage.removeItem('registration_progress');
localStorage.removeItem('wizardState');
// Check if this is a Stripe redirect after 3DS authentication
const urlParams = new URLSearchParams(window.location.search);
const setupIntent = urlParams.get('setup_intent');
const redirectStatus = urlParams.get('redirect_status');
if (setupIntent && redirectStatus) {
console.log('3DS redirect detected:', { setupIntent, redirectStatus });
// Mark as processed
sessionStorage.setItem('3ds_redirect_processed', 'true');
// Clean the URL immediately
const cleanUrl = window.location.pathname;
window.history.replaceState({}, '', cleanUrl);
if (redirectStatus === 'succeeded') {
// Retrieve pending registration data (NEW ARCHITECTURE)
const pendingDataStr = sessionStorage.getItem('pending_registration_data');
if (pendingDataStr) {
try {
setLoading(true);
const pendingData = JSON.parse(pendingDataStr);
console.log('3DS redirect detected - completing registration (NEW ARCHITECTURE)');
// Complete the registration (create user and subscription)
completeRegistrationAfterSetupIntent(setupIntent, pendingData);
} catch (err) {
console.error('Error parsing pending registration data:', err);
showToast.error('Error al procesar datos de registro', {
title: 'Error'
});
setLoading(false);
}
} else {
console.warn('No pending registration data found after 3DS redirect');
showToast.error('No se encontraron datos de registro pendientes', {
title: 'Error'
});
setLoading(false);
}
} else if (redirectStatus === 'failed') {
sessionStorage.removeItem('pending_registration_data');
showToast.error('La autenticación 3D Secure ha fallado', {
title: 'Error de autenticación'
});
setCurrentStep('payment');
setLoading(false);
}
// Clear the flag
setTimeout(() => {
sessionStorage.removeItem('3ds_redirect_processed');
}, 1000);
}
}, []); // eslint-disable-line react-hooks/exhaustive-deps
const validateForm = (): boolean => {
const newErrors: Partial<SimpleUserRegistration> = {};
@@ -219,31 +293,71 @@ export const RegisterForm: React.FC<RegisterFormProps> = ({
const handleRegistrationSubmit = async (paymentMethodId?: string) => {
try {
setLoading(true);
const registrationData = {
full_name: formData.full_name,
email: formData.email,
password: formData.password,
tenant_name: 'Default Bakery', // Default value since we're not collecting it
tenant_name: 'Default Bakery',
subscription_plan: selectedPlan,
billing_cycle: billingCycle, // Add billing cycle selection
billing_cycle: billingCycle,
payment_method_id: paymentMethodId,
// Include coupon code if pilot customer
coupon_code: isPilot ? couponCode : undefined,
// Include consent data
terms_accepted: formData.acceptTerms,
privacy_accepted: formData.acceptTerms,
marketing_consent: formData.marketingConsent,
analytics_consent: formData.analyticsConsent,
// NEW: Include billing address data for subscription creation
address: formData.address,
postal_code: formData.postal_code,
city: formData.city,
country: formData.country,
};
// Use the new registration endpoint with subscription creation
await registerWithSubscription(registrationData);
// Call registration endpoint
const response = await registerWithSubscription(registrationData);
// Validate response exists
if (!response) {
throw new Error('Registration failed: No response from server');
}
// NEW ARCHITECTURE: Check if SetupIntent verification is required
if (response.requires_action && response.client_secret) {
console.log('SetupIntent verification required (NEW ARCHITECTURE - no user created yet):', {
action_type: response.action_type,
setup_intent_id: response.setup_intent_id,
customer_id: response.customer_id,
email: response.email
});
// Store pending registration data for post-3DS completion
const pendingData = {
email: registrationData.email,
password: registrationData.password,
full_name: registrationData.full_name,
setup_intent_id: response.setup_intent_id!,
plan_id: response.plan_id!,
payment_method_id: paymentMethodId!,
billing_interval: billingCycle,
coupon_code: registrationData.coupon_code,
customer_id: response.customer_id!,
payment_customer_id: response.payment_customer_id!,
trial_period_days: response.trial_period_days ?? undefined,
client_secret: response.client_secret!
};
setPendingSubscriptionData(pendingData);
sessionStorage.setItem('pending_registration_data', JSON.stringify(pendingData));
setLoading(false);
// Handle SetupIntent confirmation (with possible 3DS)
await handleSetupIntentConfirmation(response.client_secret, paymentMethodId!, pendingData);
return;
}
// Subscription created successfully without 3DS
const successMessage = isPilot
? '¡Bienvenido al programa piloto! Tu cuenta ha sido creada con 3 meses gratis.'
: '¡Bienvenido! Tu cuenta ha sido creada correctamente.';
@@ -251,9 +365,12 @@ export const RegisterForm: React.FC<RegisterFormProps> = ({
showToast.success(t('auth:register.registering', successMessage), {
title: t('auth:alerts.success_create', 'Cuenta creada exitosamente')
});
onSuccess?.();
} catch (err) {
showToast.error(error || t('auth:register.register_button', 'No se pudo crear la account. Verifica que el email no esté en uso.'), {
setLoading(false);
const errorMessage = err instanceof Error ? err.message : 'No se pudo crear la cuenta';
showToast.error(errorMessage, {
title: t('auth:alerts.error_create', 'Error al crear la cuenta')
});
}
@@ -269,6 +386,141 @@ export const RegisterForm: React.FC<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 value = e.target.type === 'checkbox' ? e.target.checked : e.target.value;
setFormData(prev => ({ ...prev, [field]: value }));

View File

@@ -33,6 +33,7 @@ export const PaymentMethodUpdateModal: React.FC<PaymentMethodUpdateModalProps> =
}) => {
const { t } = useTranslation('subscription');
const [loading, setLoading] = useState(false);
const [authenticating, setAuthenticating] = useState(false);
const [paymentMethodId, setPaymentMethodId] = useState('');
const [stripe, setStripe] = useState<any>(null);
const [elements, setElements] = useState<any>(null);
@@ -141,24 +142,139 @@ export const PaymentMethodUpdateModal: React.FC<PaymentMethodUpdateModalProps> =
// Call backend to update payment method
const result = await subscriptionService.updatePaymentMethod(tenantId, paymentMethod.id);
if (result.success) {
setSuccess(true);
showToast.success(result.message);
// Notify parent component about the update
onPaymentMethodUpdated({
brand: result.brand,
last4: result.last4,
exp_month: result.exp_month,
exp_year: result.exp_year,
// Log 3DS requirement status
if (result.requires_action) {
console.log('3DS authentication required for payment method update', {
payment_method_id: paymentMethod.id,
setup_intent_id: result.payment_intent_id,
action_required: result.requires_action
});
// Close modal after a brief delay to show success message
setTimeout(() => {
onClose();
}, 2000);
}
if (result.success) {
// Check if 3D Secure authentication is required
if (result.requires_action && result.client_secret) {
setAuthenticating(true);
setLoading(false);
setError(null);
console.log('Starting 3DS authentication process', {
client_secret: result.client_secret.substring(0, 20) + '...', // Log partial secret for security
payment_method_id: paymentMethod.id,
timestamp: new Date().toISOString()
});
try {
// Handle 3D Secure authentication
const { error: confirmError, setupIntent } = await stripe.confirmCardSetup(result.client_secret);
console.log('3DS authentication completed', {
setup_intent_status: setupIntent?.status,
error: confirmError ? confirmError.message : 'none',
timestamp: new Date().toISOString()
});
if (confirmError) {
// Handle specific 3D Secure error types
if (confirmError.type === 'card_error') {
setError(confirmError.message || 'Card authentication failed');
} else if (confirmError.type === 'validation_error') {
setError('Invalid authentication request');
} else if (confirmError.type === 'api_error') {
setError('Authentication service unavailable');
} else {
setError(confirmError.message || '3D Secure authentication failed');
}
setAuthenticating(false);
return;
}
// Check setup intent status after authentication
if (setupIntent && setupIntent.status === 'succeeded') {
setSuccess(true);
showToast.success('Payment method updated and authenticated successfully');
console.log('3DS authentication successful', {
payment_method_id: paymentMethod.id,
setup_intent_id: setupIntent.id,
setup_intent_status: setupIntent.status,
timestamp: new Date().toISOString()
});
// Notify parent component about the update
onPaymentMethodUpdated({
brand: result.brand,
last4: result.last4,
exp_month: result.exp_month,
exp_year: result.exp_year,
});
// Close modal after a brief delay to show success message
setTimeout(() => {
onClose();
}, 2000);
} else {
console.log('3DS authentication completed with non-success status', {
setup_intent_status: setupIntent?.status,
payment_method_id: paymentMethod.id,
timestamp: new Date().toISOString()
});
setError('3D Secure authentication completed but payment method not confirmed');
setLoading(false);
}
} catch (authError) {
console.error('Error during 3D Secure authentication:', authError);
// Enhanced error handling for 3DS failures
if (authError instanceof Error) {
if (authError.message.includes('canceled') || authError.message.includes('user closed')) {
setError('3D Secure authentication was canceled. You can try again with the same card.');
} else if (authError.message.includes('failed') || authError.message.includes('declined')) {
setError('3D Secure authentication failed. Please try again or use a different card.');
} else if (authError.message.includes('timeout') || authError.message.includes('network')) {
setError('Network error during 3D Secure authentication. Please check your connection and try again.');
} else {
setError(`3D Secure authentication error: ${authError.message}`);
}
} else {
setError('3D Secure authentication error. Please try again.');
}
setAuthenticating(false);
}
} else {
// No authentication required
setSuccess(true);
showToast.success(result.message);
// Notify parent component about the update
onPaymentMethodUpdated({
brand: result.brand,
last4: result.last4,
exp_month: result.exp_month,
exp_year: result.exp_year,
});
// Close modal after a brief delay to show success message
setTimeout(() => {
onClose();
}, 2000);
}
} else {
setError(result.message || 'Failed to update payment method');
// Handle different payment intent statuses
if (result.payment_intent_status === 'requires_payment_method') {
setError('Your card was declined. Please try a different payment method.');
} else if (result.payment_intent_status === 'requires_action') {
setError('Authentication required but client secret missing. Please try again.');
} else if (result.payment_intent_status === 'processing') {
setError('Payment is processing. Please wait a few minutes and try again.');
} else if (result.payment_intent_status === 'canceled') {
setError('Payment was canceled. Please try again.');
} else {
setError(result.message || 'Failed to update payment method');
}
}
} catch (err) {
@@ -223,10 +339,28 @@ export const PaymentMethodUpdateModal: React.FC<PaymentMethodUpdateModalProps> =
</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 && (
<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" />
<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>
)}
@@ -242,18 +376,18 @@ export const PaymentMethodUpdateModal: React.FC<PaymentMethodUpdateModalProps> =
type="button"
variant="outline"
onClick={handleClose}
disabled={loading}
disabled={loading || authenticating}
>
Cancelar
</Button>
<Button
type="submit"
variant="primary"
disabled={loading || !cardElement}
disabled={loading || authenticating || !cardElement}
className="flex items-center gap-2"
>
{loading && <Loader2 className="w-4 h-4 animate-spin" />}
{loading ? 'Procesando...' : 'Actualizar Método de Pago'}
{(loading || authenticating) && <Loader2 className="w-4 h-4 animate-spin" />}
{authenticating ? 'Autenticando...' : loading ? 'Procesando...' : 'Actualizar Método de Pago'}
</Button>
</div>
</form>

View File

@@ -50,7 +50,17 @@
"next_button": "Siguiente",
"previous_button": "Anterior",
"have_account": "¿Ya tienes una cuenta?",
"sign_in_link": "Iniciar sesión"
"sign_in_link": "Iniciar sesión",
"3ds_success": "Autenticación 3D Secure completada con éxito",
"3ds_failed": "La autenticación 3D Secure ha fallado",
"3ds_complete": "Tu tarjeta ha sido verificada. Por favor, inicia sesión.",
"3ds_failed_try_again": "La autenticación 3D Secure ha fallado. Por favor, intenta de nuevo con otra tarjeta.",
"processing_3ds": "Procesando autenticación",
"3ds_error": "Error de autenticación",
"3ds_redirect_message": "Tu autenticación 3D Secure se ha completado. Serás redirigido en breve...",
"3ds_failed_message": "Hubo un problema con la autenticación 3D Secure. Serás redirigido para intentar de nuevo...",
"go_to_login": "Ir a inicio de sesión",
"try_again": "Intentar registro de nuevo"
},
"steps": {
"info": "Información",

View File

@@ -926,53 +926,296 @@
"inventoryManagement": {
"title": "Gestión de Inventario y Control de Stock",
"description": "Control completo de ingredientes con FIFO, alertas de stock bajo y reducción de desperdicios",
"readTime": "9",
"readTime": "15",
"content": {
"intro": "El inventario rastrea todos tus ingredientes: stock actual, movimientos, proveedores, costos y fechas de caducidad.",
"intro": "El sistema de inventario gestiona TODOS tus materiales en una tabla unificada: ingredientes (harina, levadura, mantequilla) Y productos finales (baguettes, croissants). Incluye tracking de lotes, FIFO automático, alertas inteligentes, trazabilidad completa HACCP, valoración de stock y auditoría. Todo integrado con producción, ventas y compras.",
"unifiedModel": {
"title": "Modelo de Inventario Unificado",
"description": "BakeWise usa UN SOLO catálogo para ingredientes y productos finales (no dos tablas separadas). Beneficios:",
"benefits": [
"Simplificación: Una sola UI para gestionar todo tu inventario (materias primas + productos terminados)",
"Transformaciones: Puedes transformar ingredientes en productos (harina + agua → masa madre → baguette) dentro del mismo sistema",
"Consistencia: Mismas reglas de stock, caducidad, FIFO aplican a todo",
"Flexibilidad: Un ítem puede ser ingrediente para una receta y producto final vendible (ej: masa madre)"
],
"structure": {
"title": "Estructura de Datos",
"fields": [
{
"field": "product_type",
"values": "INGREDIENT (materia prima) o FINISHED_PRODUCT (producto final)",
"example": "Harina T-55 = INGREDIENT, Baguette = FINISHED_PRODUCT"
},
{
"field": "ingredient_category",
"values": "Para materias primas: FLOURS, DAIRY, YEAST, SUGARS, FATS, SALTS, ADDITIVES, PACKAGING, OTHER",
"example": "Harina T-55 → FLOURS, Mantequilla → DAIRY"
},
{
"field": "product_category",
"values": "Para productos finales: BREAD, PASTRIES, CAKES, SPECIALS, OTHER",
"example": "Baguette → BREAD, Croissant → PASTRIES"
},
{
"field": "unit_of_measure",
"values": "kg, g, L, ml, units (unidades), dozen (docena)",
"example": "Harina: kg, Baguettes: units, Leche: L"
}
]
}
},
"fifoImplementation": {
"title": "Sistema FIFO (First-In-First-Out) Automático",
"description": "El corazón del control de caducidades. FIFO garantiza que SIEMPRE se usan primero los lotes más antiguos (próximos a caducar).",
"howItWorks": [
{
"step": "1. Almacenamiento por Lotes",
"description": "Cada recepción de mercancía crea un Stock (lote) independiente con: batch_number (ej: INV-20260113-001), current_quantity (100 kg), expiration_date (2026-02-15), unit_cost (0.85€/kg), received_date (2026-01-13). Múltiples lotes del mismo ingrediente coexisten en el sistema con fechas de caducidad diferentes."
},
{
"step": "2. Reserva FIFO Automática",
"description": "Cuando producción necesita consumir ingrediente (ej: 50 kg de harina para 200 baguettes): Sistema ejecuta query SQL: SELECT * FROM stock WHERE ingredient_id = X AND is_available = true ORDER BY expiration_date ASC (más antiguo primero). Reserva lotes secuencialmente empezando por el que caduca antes. Ejemplo: Lote 1 (expira 2026-02-10): 30 kg disponibles → reserva 30 kg, Lote 2 (expira 2026-02-18): 70 kg disponibles → reserva 20 kg (restantes de los 50 kg necesarios). Total consumido: 30 kg del lote antiguo + 20 kg del siguiente lote."
},
{
"step": "3. Consumo y Trazabilidad",
"description": "El sistema consume stock reservado y registra: StockMovement con movement_type = PRODUCTION_USE, reference_number = BATCH-20260113-005 (batch de producción que consumió), consumed_items array con detalles de cada lote consumido (stock_id, quantity_consumed, batch_number, expiration_date). Progressive tracking: quantity_before, quantity_after por lote. Auditoría completa: quién, cuándo, por qué, desde qué lote."
},
{
"step": "4. Actualización de Cantidades",
"description": "Para cada lote consumido: current_quantity -= consumed_qty, reserved_quantity -= consumed_qty (libera reserva), available_quantity = current_quantity - reserved_quantity, Si current_quantity = 0 → is_available = false (lote agotado, pero mantiene historial). Sistema nunca borra stocks consumidos (trazabilidad HACCP)."
}
],
"lifoOption": {
"title": "LIFO Opcional (Last-In-First-Out)",
"description": "En casos especiales puedes usar LIFO (último en entrar, primero en salir): POST /consume-stock?fifo=false. Casos de uso: Ingredientes no perecederos donde quieres rotar antiguo (usar nuevo primero para mantener antiguo como buffer), Productos congelados con shelf life muy largo (años). Por defecto: FIFO=true (99% de los casos en panadería)."
},
"stateBasedExpiration": {
"title": "Fechas de Caducidad State-Dependent",
"description": "Innovación clave: productos transformados tienen DOS fechas de caducidad. Original: expiration_date (fecha del ingrediente base, ej: masa par-baked caduca 2026-03-01), Transformada: final_expiration_date (fecha del producto final tras horneado, ej: masa fully-baked caduca 2026-01-18). Sistema usa final_expiration_date si existe, sino expiration_date. Ejemplo real: Masa par-baked shelf life 45 días → Fully baked shelf life 3 días → FIFO usa fecha de 3 días para producto final."
}
},
"features": [
{
"name": "Seguimiento en Tiempo Real",
"description": "Stock actualizado automáticamente cuando registras producciones o entregas. Ve el stock actual de cada ingrediente en tiempo real"
"name": "Seguimiento en Tiempo Real con Múltiples Lotes",
"description": "Visualización multi-lote en UI: Ingrediente: Harina T-55, Stock Total: 285 kg (suma de todos los lotes activos), Lote 1: 120 kg, expira 2026-02-10, Proveedor A, Lote 2: 95 kg, expira 2026-02-15, Proveedor A, Lote 3: 70 kg, expira 2026-02-20, Proveedor B. Color-coded por urgencia de caducidad: Rojo (expira hoy/mañana), Naranja (2-7 días), Amarillo (8-30 días), Verde (>30 días). Actualización automática: Tras producción batch completado → stock descuenta instantáneamente (WebSocket notify opcional), Tras recepción de mercancía → stock aumenta. Endpoint: GET /api/v1/tenants/{tenant_id}/inventory/stock?ingredient_id={id} → devuelve array de lotes con detalles completos."
},
{
"name": "FIFO Automático",
"description": "First-In-First-Out (primero en entrar, primero en salir). El sistema consume automáticamente los lotes más antiguos para evitar caducidades"
"name": "Alertas de Stock Bajo Multi-Nivel",
"description": "Sistema de alertas con 2 umbrales configurables por ingrediente: Umbral 1: low_stock_threshold (alerta preventiva, ej: 80 kg). Genera alerta severity='medium', alert_type='low_stock', Acción sugerida: 'Revisar consumo, considerar pedido'. Umbral 2: reorder_point (alerta crítica, ej: 50 kg). Genera alerta severity='high', alert_type='reorder_needed', Acción sugerida: 'HACER PEDIDO URGENTE', Incluye: suggested_order_quantity (cantidad estándar de pedido), supplier_lead_time_days (tiempo de entrega del proveedor), hours_until_stockout (cuántas horas quedan al ritmo actual de consumo). Cálculo inteligente: Sistema analiza consumo últimos 7 días → daily_usage = avg(consumo_diario), days_of_stock = current_stock / daily_usage. Priorización: Alertas ordenadas por hours_until_stockout ASC (primero el más urgente). Notificaciones: Dashboard badge rojo con número de alertas críticas, Email/WhatsApp (si configurado) para alertas CRITICAL, Recomendación de orden de compra auto-generada. Endpoint: GET /api/v1/tenants/{tenant_id}/inventory/stock/low-stock → devuelve array de ingredientes críticos."
},
{
"name": "Alertas de Stock Bajo",
"description": "Cuando un ingrediente llega al punto de reorden, recibes alerta. Ejemplo: 'Harina T-55: stock 15kg, mínimo 50kg, hacer pedido'"
"name": "Gestión Avanzada de Caducidades",
"description": "Sistema multi-stage de alertas de caducidad: CRÍTICO (1-2 días antes): severity='critical', alert_type='urgent_expiry', Acción: 'Usar HOY o marcar como waste', Notificación inmediata. ALTO (3-7 días antes): severity='high', alert_type='expiring_soon', Acción: 'Priorizar uso en producciones', Dashboard alert. MEDIO (8-14 días antes): severity='medium', alert_type='expiring_warning', Acción: 'Planificar consumo', Solo en reportes. Configuración flexible: days_ahead parameter (default: 7, range: 1-365), Personalizable por ingrediente si necesario. Endpoint: GET /api/v1/tenants/{tenant_id}/inventory/stock/expiring?days_ahead=7. Gestión de expirados: Scheduler automático ejecuta diariamente (3 AM): Marca stocks con expiration_date < today como quality_status='expired', Cambia is_available=false (bloquea consumo), Crea alerta inventory.expired_products con lista completa, Genera reporte de waste por caducidad (kg + €). Workflow de waste: Operador revisa expirados en Dashboard → Waste Tracker, Confirma waste → Crea StockMovement type='WASTE' con waste_reason='expired', Stock movido a historial, Métrica de waste actualizada."
},
{
"name": "Gestión de Caducidades",
"description": "Registra fechas de caducidad de cada lote. Alertas 7 días antes de caducar. Seguimiento de desperdicio por caducidad"
"name": "Batch/Lot Management Completo",
"description": "Cada lote (Stock) rastrea: Identificación: batch_number (ej: INV-20260113-001, auto-generado), lot_number (número de lote del proveedor, ej: LOT-2026-A123), supplier_batch_ref (referencia interna del proveedor). Fechas: received_date (cuándo llegó), expiration_date (caducidad original), best_before_date (consumir preferentemente antes de), original_expiration_date (fecha pre-transformación), transformation_date (cuándo se transformó, ej: par-baked → fully baked), final_expiration_date (caducidad post-transformación). Calidad: quality_status (good, damaged, expired, quarantined), Si 'quarantined' → bloqueado para uso hasta inspección/aprobación manager. Almacenaje: storage_location (zona de almacén, estante, ej: 'Zona Fría A, Estante 3'), requires_refrigeration (boolean), requires_freezing (boolean), storage_temperature_min/max (ej: 2-8°C para lácteos), storage_humidity_max (ej: 60% para harinas), shelf_life_days (vida útil en días desde received_date). Costos: unit_cost (€/unidad de medida), total_cost (unit_cost × current_quantity), Actualizado automáticamente al consumir stock. UI BatchModal: Lista de todos los lotes de un ingrediente, Cards colapsables (por UX, empiezan collapsed), Acciones por lote: Edit (cantidad, fecha caducidad, ubicación, calidad), Mark as Waste (convierte a WASTE movement), Delete (admin only, soft delete). Endpoint: GET /api/v1/tenants/{tenant_id}/inventory/stock?ingredient_id={id}."
},
{
"name": "Transformaciones de Productos (Production Stages)",
"description": "Sistema soporta transformar ingredientes a través de etapas de producción: Etapas disponibles: raw_ingredient (comprado del proveedor), prepared_dough (masa preparada pero no horneada), par_baked (pre-horneado), fully_baked (horneado completo), frozen_product (congelado intermedio). Workflow de transformación: Endpoint: POST /api/v1/tenants/{tenant_id}/inventory/transformations, Body: source_ingredient_id (ej: masa par-baked), target_ingredient_id (ej: baguette fully baked), source_quantity (100 unidades par-baked), target_quantity (90 unidades fully baked, 10% loss por merma), expiration_calculation_method: 'days_from_transformation', expiration_days_offset: 3 (baguette fully baked dura 3 días desde horneado). Sistema ejecuta: 1) Reserva source stock usando FIFO, 2) Consume source (crea PRODUCTION_USE movement), 3) Crea nuevo Stock para target con: current_quantity = 90, production_stage = fully_baked, original_expiration_date = fecha del lote par-baked, transformation_date = now(), final_expiration_date = now() + 3 días, 4) Registra Transformation record para trazabilidad. Beneficios: Trazabilidad completa de lote source → target, FIFO usa fecha correcta (3 días final, no 45 días original), Contabilidad precisa de mermas/loss en transformación, Auditoría HACCP (qué lote par-baked generó qué baguettes)."
}
],
"stockEntry": [
"stockOperations": {
"title": "Operaciones de Stock (Entradas, Salidas, Ajustes)",
"movements": [
{
"type": "PURCHASE (Entrada por Compra)",
"description": "Cuando recibes mercancía del proveedor: POST /api/v1/tenants/{tenant_id}/inventory/stock, Body: ingredient_id, current_quantity (ej: 150 kg), unit_cost (ej: 0.85€/kg), expiration_date (opcional, requerido para perecederos), batch_number (auto-generado si no se provee), supplier_id (opcional), production_stage (default: raw_ingredient), quality_status (default: good), storage_location, storage_temperature_min/max (para refrigerados), notes. Sistema ejecuta: Crea Stock record, Genera batch_number único (formato: INV-YYYYMMDD-NNN), Calcula total_cost = current_quantity × unit_cost, Crea StockMovement type='PURCHASE', movement_type='IN', Actualiza weighted_average_cost del ingrediente: WAC_nuevo = (qty_anterior × WAC_anterior + qty_nueva × cost_nuevo) / (qty_anterior + qty_nueva), Actualiza last_purchase_price, Actualiza total stock available. Tiempo: <200ms. UI: AddStockModal con campos dinámicos según tipo de ingrediente."
},
{
"type": "PRODUCTION_USE (Salida por Producción)",
"description": "Consumo automático cuando batch de producción se completa: Trigger: ProductionBatch status = COMPLETED, Sistema llama: POST /consume-stock?ingredient_id={id}&quantity=50&reference_number=BATCH-001&fifo=true. Sistema ejecuta: Reserva stock usando FIFO (query ORDER BY expiration_date ASC), Consume secuencialmente de lotes más antiguos, Crea StockMovement por cada lote consumido con: movement_type = PRODUCTION_USE, reference_number = BATCH-20260113-005, quantity_before/after por lote (progressive tracking), batch_info (de qué lotes se consumió, cuánto de cada uno), Devuelve: total_quantity_consumed, consumed_items array [{stock_id, quantity_consumed, batch_number, expiration_date}]. Ejemplo response: {'total_quantity_consumed': 50, 'consumed_items': [{'stock_id': '...', 'quantity_consumed': 30, 'batch_number': 'INV-001', 'expiration_date': '2026-02-10'}, {'stock_id': '...', 'quantity_consumed': 20, 'batch_number': 'INV-002', 'expiration_date': '2026-02-15'}]}. Trazabilidad completa: sabes exactamente qué batch de producción consumió qué lotes de ingredientes."
},
{
"type": "WASTE (Salida por Desperdicio)",
"description": "Registro de merma, caducidad, daño: UI: BatchModal → Select lote → 'Mark as Waste', Modal aparece: waste_quantity (cuánto se desperdicia, puede ser parcial o total del lote), waste_reason (expired, damaged, contaminated, overproduction, quality_issue, other), notes (descripción libre). POST /api/v1/tenants/{tenant_id}/inventory/stock/movements, Body: movement_type='WASTE', stock_id, quantity, waste_reason, notes. Sistema: Descuenta cantidad del lote, Crea StockMovement type='WASTE', Actualiza métricas de waste (kg, €, % del total), Si quantity = total del lote → is_available=false. Reportes: Dashboard → Waste Tracker → waste_by_reason (expired 60%, damaged 25%, overproduction 15%), waste_trend (últimos 30 días), waste_cost (€ perdidos). Objetivo típico: <5% waste rate (waste/total_purchases)."
},
{
"type": "TRANSFER (Transferencia entre Ubicaciones)",
"description": "Para multi-tenant (obrador + sucursales) o multi-location (almacén → producción): POST /api/v1/tenants/{tenant_id}/inventory/stock/movements, Body: movement_type='TRANSFER', stock_id, quantity, from_location (ej: 'Almacén Central'), to_location (ej: 'Sucursal Centro'), reference_number (ej: TRANSFER-001), notes. Sistema: Descuenta cantidad de from_location stock, Crea nuevo Stock en to_location con misma info (batch_number, expiration_date, unit_cost copiados), Crea 2 StockMovements: OUT en from_location, IN en to_location, Mantiene trazabilidad (mismo batch_number en ambas ubicaciones). Caso de uso: Obrador produce pan → transfiere a 3 sucursales para venta."
},
{
"type": "ADJUSTMENT (Ajuste Manual)",
"description": "Correcciones por inventario físico, errores de registro, pérdida no identificada: PUT /api/v1/tenants/{tenant_id}/inventory/stock/{stock_id}, Body: current_quantity (nueva cantidad real tras inventario físico), adjustment_reason (physical_inventory, data_error, loss_unidentified, found_extra), notes (explicación). Sistema: Calcula difference = new_quantity - old_quantity, Crea StockMovement type='ADJUSTMENT', movement_type='IN' si difference > 0, 'OUT' si < 0, quantity = abs(difference), Registra quantity_before, quantity_after, user_id (quién hizo ajuste), created_at (timestamp). Auditoría: Todos los ajustes se revisan en Dashboard → Stock History → Filtrar por ADJUSTMENT. Alertas: Si ajuste > 10% del stock → genera alert para manager review (posible error o theft)."
}
]
},
"inventoryValuation": {
"title": "Valoración de Inventario y Contabilidad",
"methods": [
{
"method": "Weighted Average Cost (WAC) - Método Principal",
"description": "BakeWise usa WAC (Costo Promedio Ponderado) por defecto. Fórmula: WAC = (Stock_Actual × WAC_Actual + Compra_Nueva × Costo_Nuevo) / (Stock_Actual + Compra_Nueva). Ejemplo: Tienes 100 kg harina @ 0.80€/kg (WAC actual), Compras 50 kg @ 1.00€/kg (compra nueva), Cálculo: WAC_nuevo = (100×0.80 + 50×1.00) / (100+50) = (80 + 50) / 150 = 0.867€/kg. Nuevo stock: 150 kg @ 0.867€/kg. Ventajas WAC: Simple y justo (promedia fluctuaciones de precio), Cumple con normativa contable española (PGC-PYMES), Actualización automática en cada compra. Campos en Ingredient: average_cost (WAC actual, usado para valoración), last_purchase_price (último precio pagado, referencia), standard_cost (coste estándar/presupuestado, opcional para variance analysis)."
},
{
"method": "FIFO Costing (Opcional para Reportes)",
"description": "Aunque valoración usa WAC, puedes generar reportes con FIFO costing para análisis: Costo FIFO = suma de (qty_consumida_lote_i × unit_cost_lote_i) por orden de consumo FIFO. Útil para: Comparar cost_real vs cost_standard, Identificar impacto de variaciones de precio proveedor, Auditorías que requieren método FIFO. Endpoint: GET /api/v1/tenants/{tenant_id}/inventory/analytics?valuation_method=FIFO (genera reporte con FIFO costing). No afecta contabilidad principal (WAC sigue siendo oficial)."
}
],
"metrics": [
{
"metric": "Valor Total de Inventario",
"calculation": "Σ(current_quantity × average_cost) para todos los stocks activos (is_available=true)",
"dashboard": "Dashboard → Inventory Summary → Total Stock Value: 12,450€"
},
{
"metric": "Rotación de Inventario (Inventory Turnover)",
"calculation": "Turnover = Cost_of_Goods_Sold (COGS) / Average_Inventory_Value. Ejemplo: COGS últimos 12 meses: 120,000€, Inventory promedio: 15,000€, Turnover = 120,000 / 15,000 = 8× al año (renuevas inventario 8 veces/año). Benchmark panadería: 12-20× (productos frescos rotan rápido)",
"dashboard": "Dashboard → Analytics → Inventory Turnover: 8.2× (últimos 12 meses)"
},
{
"metric": "Days of Inventory on Hand (DOH)",
"calculation": "DOH = 365 / Turnover. Ejemplo: Turnover 8× → DOH = 365/8 = 45.6 días promedio. Interpretación: En promedio, un ingrediente permanece en inventario 45 días antes de usarse. Objetivo panadería: 15-30 días (frescos), 30-60 días (secos/no perecederos)",
"dashboard": "Dashboard → Analytics → Days of Inventory: 45.6 días"
},
{
"metric": "Waste Rate (Tasa de Desperdicio)",
"calculation": "Waste_Rate = (Total_Waste_kg / Total_Purchases_kg) × 100. Ejemplo: Waste últimos 30 días: 50 kg, Purchases últimos 30 días: 1,200 kg, Waste_Rate = (50/1200) × 100 = 4.17%. Benchmark industria: <5% excelente, 5-10% aceptable, >10% problemático",
"dashboard": "Dashboard → Waste Tracker → Waste Rate: 4.2% (último mes, -1.5% vs mes anterior)"
},
{
"metric": "Waste Cost (Coste de Desperdicio)",
"calculation": "Waste_Cost = Σ(waste_quantity × unit_cost) para todos WASTE movements. Incluye breakdown por waste_reason: expired: 120€ (60%), damaged: 50€ (25%), overproduction: 30€ (15%)",
"dashboard": "Dashboard → Waste Tracker → Total Waste Cost: 200€ (último mes)"
}
]
},
"complianceAndAudit": {
"title": "Cumplimiento HACCP y Trazabilidad",
"features": [
{
"feature": "Trazabilidad Forward & Backward",
"description": "Forward Tracing (de ingrediente a producto final): Pregunta: '¿Dónde fue usado lote INV-001 de harina?' Query: SELECT * FROM stock_movements WHERE stock_id = INV-001 AND movement_type = PRODUCTION_USE. Resultado: Usado en BATCH-20260113-005 (200 baguettes), BATCH-20260113-012 (150 pan rústico). Conclusión: Si lote INV-001 tiene contaminación, sabes exactamente qué productos finales afectó. Backward Tracing (de producto final a ingredientes): Pregunta: '¿Qué lotes de ingredientes se usaron en BATCH-005?' Query: SELECT * FROM stock_movements WHERE reference_number = BATCH-005 AND movement_type = PRODUCTION_USE. Resultado: Harina lote INV-001 (30 kg), Harina lote INV-002 (20 kg), Levadura lote INV-015 (0.8 kg), Sal lote INV-020 (2 kg). Conclusión: Si cliente reporta problema con BATCH-005, rastrear ingredientes exactos usados. Endpoint: GET /api/v1/tenants/{tenant_id}/inventory/stock/movements?stock_id={id} o ?reference_number={batch}. Tiempo de query: <100ms (indexes optimizados)."
},
{
"feature": "Auditoría Completa de Movimientos",
"description": "Cada StockMovement registra 15+ campos de auditoría: movement_type (PURCHASE, PRODUCTION_USE, WASTE, TRANSFER, ADJUSTMENT), stock_id (qué lote afectado), ingredient_id (qué ingrediente), tenant_id (multi-tenant isolation), user_id (quién ejecutó movimiento), quantity (cuánto movido), movement_date (timestamp preciso con timezone), reference_number (link a orden de compra, batch de producción, etc.), notes (comentarios libres), quantity_before (cantidad antes del movimiento), quantity_after (cantidad después), batch_number (trazabilidad de lote), expiration_date (fecha caducidad del lote afectado), cost_per_unit (valoración en momento del movimiento), total_cost (quantity × cost_per_unit). Export: GET /api/v1/tenants/{tenant_id}/inventory/stock/movements?export=true&format=excel → descarga Excel con TODOS los movimientos filtrados. Filters disponibles: date_range (ej: últimos 90 días para auditoría trimestral), ingredient_id, movement_type, user_id (movimientos de un operador específico). Uso: Auditorías internas/externas, Investigaciones de discrepancias, Análisis de consumo histórico, Compliance HACCP/ISO 22000."
},
{
"feature": "Food Safety Alerts & Compliance",
"description": "Sistema de alertas específicas de seguridad alimentaria: Temperature Breach: Si temperature_log fuera de rango (storage_temperature_min/max) por >30 min → alerta 'food_safety.temperature_breach'. Data: sensor_id, location (ej: 'Cámara Fría 1'), temperature_actual, temperature_target, duration_minutes, affected_stocks (qué lotes estaban en esa ubicación). Acción: Inspeccionar productos afectados, potencial quarantine. Expired in Cold Chain: Productos refrigerados expirados → severity='critical' (mayor riesgo que secos). Cross-Contamination Risk: Si lote marcado 'allergen:gluten' se mueve a location usada para 'allergen:none' → alerta. Quarantine Management: Lotes con quality_status='quarantined' bloqueados para producción hasta: Manager ejecuta quality inspection, Aprueba (cambia a 'good') o Rechaza (marca como WASTE). Compliance Reports: GET /api/v1/tenants/{tenant_id}/inventory/food-safety/compliance?period=monthly. Genera reporte con: Temperature breach incidents, Expired items timeline, Quarantine actions log, HACCP checklist compliance %. Exportable para auditorías sanitarias."
},
{
"feature": "Batch Recall Simulation",
"description": "Próximamente: herramienta de simulación de retirada de lote. Input: batch_number (ej: INV-001 contaminado). Sistema calcula automáticamente: Qué productos finales usaron ese lote (forward tracing), Cuántas unidades vendidas de esos productos (link con Sales), A qué clientes (si B2B con customer tracking), Sugerencias de recall notice, Coste estimado de la retirada. Output: Reporte detallado para gestión de crisis. Objetivo: Respuesta <1 hora en caso de alerta sanitaria (vs días manualmente)."
}
]
},
"uiComponents": {
"title": "Interfaz de Usuario (Frontend)",
"components": [
{
"component": "InventoryPage.tsx",
"path": "/dashboard/inventory",
"description": "Página principal de inventario. Vista de tabla: Columnas: Nombre, Categoría, Stock Actual, Unidad, Status (Low Stock, Expiring, OK, Overstock, Expired), Acciones. Filtros: Por categoría (FLOURS, DAIRY, BREAD, PASTRIES, etc.), Por status (low_stock, expiring, expired, all), Búsqueda por nombre (real-time). Status badges: Color-coded (rojo=crítico, naranja=warning, verde=OK), Iconos (⚠️ low stock, ⏰ expiring, ❌ expired, 📦 overstock), Click en badge → detalle modal. Acciones rápidas: + Add Stock (abre AddStockModal), View Batches (abre BatchModal con lotes del ingrediente), View History (abre StockHistoryModal con movimientos), Edit Ingredient (editar nombre, categoría, umbrales). Performance: Paginación 50 items/página, Lazy loading, Cache 2 min (staleTime: 120s). Endpoint: GET /api/v1/tenants/{tenant_id}/inventory/ingredients con stock aggregation."
},
{
"component": "AddStockModal.tsx",
"description": "Modal para añadir entrada de stock (compra, recepción). Form sections: Ingredient Selection: Dropdown con todos los ingredientes, Autocomplete con búsqueda, Muestra average_cost actual del ingrediente. Quantity & Cost: current_quantity (required, number), unit_cost (opcional, default a average_cost del ingrediente), total_cost (auto-calculado = qty × cost, read-only). Batch Information: batch_number (auto-generado formato INV-YYYYMMDD-NNN, editable), lot_number (número lote proveedor, opcional), supplier_id (dropdown de proveedores, opcional). Expiration & Storage: expiration_date (date picker, requerido si ingrediente es perecedero), best_before_date (opcional), production_stage (raw_ingredient default), quality_status (good default, opciones: good, damaged, quarantined), storage_location (text, ej: 'Almacén A, Estante 2'), requires_refrigeration (checkbox, auto-checked si ingrediente requiere), storage_temperature_min/max (si refrigerado, ej: 2-8°C). Notes: Textarea libre para observaciones. Validations: Quantity > 0, Unit cost >= 0, Expiration date > today (warning si no, permite override), Storage temp dentro de rango aceptable para ingrediente. Submit → POST /api/v1/tenants/{tenant_id}/inventory/stock → Success: Modal cierra, Lista se refresca, Toast notification 'Stock añadido: 150 kg Harina T-55'."
},
{
"component": "BatchModal.tsx",
"description": "Modal para gestionar lotes de un ingrediente. Layout: Header: Ingrediente: Harina T-55, Stock Total: 285 kg (suma de lotes), Botón 'Add New Batch'. Batch Cards: Array de cards, uno por lote, Collapsible (collapsed por default para UX limpia), Expand al click. Batch Card Content: Batch Number: INV-20260113-001 (bold), Quantity: 120 kg / 150 kg original (current / initial), Expiration: 2026-02-15 (32 días restantes) → color-coded, Supplier: Harinera La Espiga, Cost: 0.85€/kg (102€ total), Quality: good ✓ (badge verde), Location: Almacén A, Estante 2, Storage: Refrigeration not required (icon + text). Actions por batch: Edit: Abre EditBatchModal (cambiar quantity, expiration, location, quality), Mark as Waste: Abre WasteModal (ingresar waste_quantity, waste_reason), Delete: Admin only, confirmación modal, soft delete. Color coding expiration: Rojo: Expired o expira hoy/mañana, Naranja: 2-7 días, Amarillo: 8-30 días, Gris: >30 días. Sorting: Por expiration_date ASC (más urgente primero, FIFO order). Endpoint: GET /api/v1/tenants/{tenant_id}/inventory/stock?ingredient_id={id}."
},
{
"component": "StockHistoryModal.tsx",
"description": "Modal de historial de movimientos. Filters: Date range picker (default: últimos 30 días), Movement type multi-select (PURCHASE, PRODUCTION_USE, WASTE, TRANSFER, ADJUSTMENT, ALL), User filter (dropdown de usuarios, opcional). Table Columns: Date (timestamp), Type (badge color-coded: PURCHASE=green, PRODUCTION_USE=blue, WASTE=red, TRANSFER=orange, ADJUSTMENT=yellow), Quantity (con + o - prefix según IN/OUT), Batch Number, Reference (link a PO, production batch, etc.), User (quién ejecutó), Notes. Pagination: 20 movimientos/página. Export: Botón 'Export to Excel' → descarga historial filtrado. Drill-down: Click en Reference Number (ej: BATCH-005) → navega a batch detail page, Click en Batch Number (ej: INV-001) → abre BatchModal con ese lote específico. Endpoint: GET /api/v1/tenants/{tenant_id}/inventory/stock/movements?ingredient_id={id}&start_date=X&end_date=Y&movement_type=Z"
},
{
"component": "WasteTrackerWidget.tsx",
"path": "/dashboard (widget)",
"description": "Widget de dashboard para monitorear desperdicio. Métricas principales: Total Waste (último mes): 50 kg, 200€, Waste Rate: 4.2% (verde si <5%, amarillo 5-10%, rojo >10%), Trend: ↓ -1.5% vs mes anterior (verde). Breakdown por waste_reason: Chart de dona (pie chart) con: Expired: 60% (30 kg), Damaged: 25% (12.5 kg), Overproduction: 10% (5 kg), Other: 5% (2.5 kg). Top 3 wasted ingredients: Harina T-55: 15 kg (30€), Mantequilla: 8 kg (80€, más costoso aunque menos kg), Leche: 12 L (18€). Actions: Click en ingredient → abre detail modal con waste history, Link 'See Full Report' → navega a /analytics/waste. Actualización: Polling cada 5 min (o WebSocket si disponible). Endpoint: GET /api/v1/tenants/{tenant_id}/inventory/analytics/waste?period=30d"
}
]
},
"integrationPoints": {
"title": "Integraciones con Otros Módulos",
"integrations": [
{
"module": "Production Service",
"description": "Bidireccional: Production → Inventory (consumo automático): Cuando ProductionBatch status = COMPLETED, Production Service llama InventoryClient.consume_stock(ingredient_ids, quantities, reference=batch_id, fifo=true), Inventory descuenta stock usando FIFO, devuelve consumed_items detail, Production registra qué lotes se usaron en batch metadata. Inventory → Production (alertas de disponibilidad): Antes de crear batch, Production consulta InventoryClient.check_availability(ingredient_requirements), Si algún ingrediente insuficiente → batch status = ON_HOLD, alerta al manager. Validation en tiempo real: UI de creación de batch muestra stock actual de ingredientes necesarios con color-coding (verde=OK, rojo=insuficiente)."
},
{
"module": "Sales Service",
"description": "Sales → Inventory (producto final sale): Cuando venta registrada, Sales puede decrementar stock de producto final (opcional, depende de configuración), Si enabled: SalesClient llama InventoryClient.consume_stock(product_id, quantity_sold, reference=sale_id), Útil para tracking de productos finales en inventario (no solo ingredientes). Inventory → Sales (datos para predicciones): Forecasting Service usa inventory levels para ajustar predicciones: Si stock alto de producto X → puede bajar producción sugerida, Si stock bajo → aumentar urgencia de producción."
},
{
"module": "Procurement Service (Compras)",
"description": "Inventory → Procurement (auto-generación de órdenes): Sistema detecta ingredient con stock < reorder_point, Genera PurchaseOrderSuggestion automática con: ingredient_id, suggested_quantity (= reorder_quantity configurado), supplier_id (proveedor principal del ingrediente), estimated_cost (= suggested_qty × last_purchase_price), urgency (= hours_until_stockout), Manager revisa sugerencias en Dashboard → Procurement → Suggested Orders, Click 'Create PO' → auto-llena formulario de orden de compra. Procurement → Inventory (recepción de mercancía): Cuando PurchaseOrder delivered, Procurement notifica Inventory, UI muestra 'Pending Receipt' para esa PO, Click → auto-llena AddStockModal con datos de la PO (ingredient, quantity, supplier, cost), Confirmar → crea stock entry linked a PO."
},
{
"module": "Analytics Service",
"description": "Inventory → Analytics (data streaming): Todos los movimientos de stock se publican como eventos a Analytics Service, Analytics agrega datos para: Consumo histórico por ingrediente (tendencias), Waste analytics (causas, costes, trends), Inventory turnover por categoría, Stock value fluctuations. Analytics → Inventory (recomendaciones): Analytics detecta patrones: 'Harina T-55: consumo aumentó 25% últimos 30 días → sugerir aumentar reorder_point de 50 kg a 65 kg', 'Mantequilla: waste por caducidad 15% último mes → sugerir reducir reorder_quantity o negociar compras más frecuentes con proveedor', Dashboard → Inventory → Optimization Recommendations muestra sugerencias AI-powered."
}
]
},
"bestPractices": [
{
"title": "Recepción de Pedidos",
"description": "Cuando llega un pedido del proveedor: 1) Registra la entrega en el sistema, 2) Escanea código de barras o introduce cantidad manualmente, 3) Registra lote y fecha de caducidad, 4) Stock se actualiza automáticamente"
"practice": "Configurar Umbrales Realistas",
"description": "Fórmula reorder_point: reorder_point = (daily_usage × supplier_lead_time_days) × safety_factor. Ejemplo: Harina T-55: daily_usage = 50 kg/día (promedio últimos 30 días), lead_time = 2 días (tu proveedor entrega en 2 días), safety_factor = 1.2 (20% buffer para variabilidad), reorder_point = 50 × 2 × 1.2 = 120 kg. Interpretación: Cuando stock baje de 120 kg, hacer pedido. Con consumo de 50 kg/día, tienes 2.4 días de stock (tiempo suficiente para recibir pedido en 2 días). Ajustar safety_factor: 1.1-1.2 (10-20%) para ingredientes estables y proveedores fiables, 1.3-1.5 (30-50%) para ingredientes volátiles o proveedores con lead time variable, 1.5-2.0 (50-100%) para ingredientes críticos sin sustituto."
},
{
"title": "Actualización Automática",
"description": "Al completar un lote de producción, el sistema descuenta automáticamente los ingredientes usando FIFO. Si produces 100 baguettes (receta: 500g harina cada), descuenta 50kg de harina del lote más antiguo"
}
],
"compliance": [
{
"name": "HACCP",
"description": "Sistema cumple con normas HACCP de seguridad alimentaria. Trazabilidad completa de lotes, temperaturas de almacenaje"
"practice": "Inventario Físico Regular",
"description": "Frecuencia recomendada: Inventario completo: Trimestral (cada 3 meses) para todos los ingredientes, Inventario parcial (ABC analysis): Categoría A (alto valor, 20% ingredientes, 80% valor): Mensual, Categoría B (valor medio, 30% ingredientes, 15% valor): Bimestral, Categoría C (bajo valor, 50% ingredientes, 5% valor): Trimestral. Proceso: 1) Generar reporte de stock teórico: GET /inventory/stock/summary, 2) Contar físicamente (mejor fin de mes cuando stock es bajo), 3) Comparar stock_teorico vs stock_fisico, 4) Si discrepancia > 5%: Investigar (pérdida, theft, error de registro), Ajustar: PUT /stock/{id} con adjustment_reason='physical_inventory', 5) Analizar tendencias de discrepancias (si sistemáticas → problema de proceso). Beneficios: Detecta mermas ocultas (evaporación, derrames, robo), Corrige errores acumulados de registro, Mantiene confianza en datos del sistema, Cumple normativa contable (inventarios auditables)."
},
{
"name": "Auditoría",
"description": "Historial completo de movimientos de stock para auditorías. Exportable a PDF/Excel"
"practice": "Rotación FIFO Disciplinada",
"description": "Aunque sistema consume FIFO automáticamente en producción, el almacenaje físico debe facilitar FIFO: Organización de almacén: Lotes nuevos atrás, antiguos adelante (operadores toman de adelante naturalmente), Etiquetado claro: Fecha recepción + Fecha caducidad visible en todos los lotes, Zonas separadas por urgencia: Zona A (expira 0-7 días): Usar PRIMERO, prominente, Zona B (expira 8-30 días): Usar segundo, Zona C (>30 días): Stock de reserva. Auditoría física semanal: Revisar que lotes adelante son realmente los más antiguos (comparar etiquetas vs ubicación), Si encuentras lote antiguo atrás → mover a adelante. Capacitación personal: Entrenar equipo en FIFO importance, Consecuencias de no seguir FIFO (caducidades, waste, coste). Métricas de compliance: Waste_by_expiration: Si >60% del waste es por caducidad → posible problema de FIFO físico (vs otros waste reasons como damaged, overproduction)."
},
{
"practice": "Monitoreo de Caducidades Proactivo",
"description": "No esperar alertas del sistema, revisar proactivamente: Daily check (5 min cada mañana): Dashboard → Expiring Stock Widget → Items expiring next 3 days, Planificar uso inmediato (incluir en producción del día o día siguiente). Weekly review (15 min cada lunes): GET /stock/expiring?days_ahead=14 → items expiring próximas 2 semanas, Priorizar en plan de producción semanal, Considerar promociones (vender productos con ingredientes próximos a caducar a precio reducido para mover stock rápido). Monthly analysis (30 min fin de mes): Waste report → % waste por expired, Identificar ingredientes con alta tasa de caducidad (compras excesivas? shelf life corto?), Ajustar reorder_quantity o negociar compras más frecuentes/pequeñas con proveedor. Automation: Configurar notificaciones automáticas: Email diario con items expiring <3 days (criticales), Email semanal con items expiring <7 days (planificar)."
},
{
"practice": "Optimización de Niveles de Stock",
"description": "Revisar y ajustar trimestralmente: Analizar consumo real: GET /inventory/analytics?metric=consumption&period=90d → consumption_daily_avg por ingrediente, Comparar con reorder_point actual: Si consumo aumentó: Subir reorder_point (evitar roturas de stock), Si consumo bajó: Bajar reorder_point (reducir capital inmovilizado). Optimizar reorder_quantity: Economic Order Quantity (EOQ) formula: EOQ = √[(2 × annual_demand × order_cost) / holding_cost_per_unit], Ejemplo: Harina demand anual: 18,000 kg, Order cost (logística, admin): 25€/pedido, Holding cost (almacenaje, capital): 0.50€/kg/año, EOQ = √[(2×18000×25) / 0.50] = √1,800,000 = 1,342 kg por pedido. Balancear vs supplier constraints: Si EOQ sugiere 1,342 kg pero proveedor vende en sacos de 25 kg → redondear a 1,350 kg (54 sacos), Si proveedor ofrece descuento por volumen >2,000 kg → evaluar trade-off (ahorro compra vs coste almacenaje extra). Resultado: Minimize total cost = purchase cost + order cost + holding cost."
}
],
"metrics": [
"Valor total de inventario (€)",
"Rotación de inventario (cuántas veces al año renuevas stock)",
"Desperdicio por caducidad (kg y € al mes)",
"Ingredientes con stock crítico"
]
"troubleshooting": [
{
"problem": "Discrepancias persistentes entre stock teórico y físico",
"solutions": [
"Auditoría de movimientos: GET /stock/movements?ingredient_id=X&date_range=last_30d → revisar TODOS los movimientos, buscar: Movimientos sin reference_number (posibles errores manuales), Ajustes frecuentes del mismo user_id (posible error sistemático o fraude), Consumos que no matchean con production batches completados",
"Verificar proceso de registro: ¿Personal registra TODAS las salidas (production, waste, transfers)?, ¿Hay consumos ad-hoc no registrados (chef toma ingredientes para pruebas)?, Implementar: Control de acceso físico a almacén (solo personal autorizado), Registro obligatorio de salidas (formulario, incluso para pequeñas cantidades)",
"Investigar pérdidas naturales: Ingredientes con evaporación (líquidos en contenedores no herméticos), Merma por manipulación (bolsas rotas, derrames), Condensación/humedad (cambios de peso en harinas), Solución: Registrar allowance de merma normal (ej: 1-2% en harinas), Comparar discrepancia vs allowance",
"Automatizar cuando sea posible: Si discrepancias en producción → integrar balanzas digitales con sistema (auto-registro de consumos), Si discrepancias en recepción → escaneo de códigos de barras al recibir (evita errores de entrada manual)"
]
},
{
"problem": "Alertas de stock bajo muy frecuentes (rotura de stock continua)",
"solutions": [
"Recalcular reorder_point: Fórmula actual: reorder_point = daily_usage × lead_time × 1.2, Revisar daily_usage: GET /analytics?metric=consumption&period=30d → usar promedio más reciente (últimos 30 días, no 90+ días si consumo cambió), Revisar lead_time: ¿Proveedor entrega realmente en 2 días o suele tardar 3-4? Usar lead_time_real máximo observado, Aumentar safety_factor de 1.2 a 1.3-1.5 si: Proveedor poco fiable (retrasos frecuentes), Consumo muy variable (CV >0.5), Ingrediente crítico sin sustituto",
"Optimizar reorder_quantity: Si haces pedidos pequeños → aumentar reorder_quantity para reducir frecuencia de pedidos, Negociar entregas más frecuentes con proveedor (semanal vs quincenal) → permite reducir reorder_point (menos stock de seguridad necesario)",
"Automatizar pedidos: Configurar auto-purchase orders para ingredientes críticos estables, Cuando stock < reorder_point → sistema crea PO automáticamente, Proveedor recibe email con pedido, Confirma delivery date, Manager solo aprueba (no crea manualmente)",
"Alertas tempranas: Configurar alert cuando stock = 1.5 × reorder_point (50% antes del punto crítico) → da más margen para actuar"
]
},
{
"problem": "Waste alto por caducidades (>10% del total)",
"solutions": [
"Analizar causas raíz: GET /analytics/waste?waste_reason=expired&period=90d, Breakdown por ingrediente: ¿Pocos ingredientes causan mayoría del waste? (Pareto 80/20), Top offenders → investigar específicamente cada uno",
"Soluciones por causa: Compras excesivas: Reducir reorder_quantity (comprar más frecuente, cantidades menores), Shelf life corto: Negociar con proveedor entregas más frecuentes (semanal vs quincenal), Productos de lento movimiento reducen frescura de rápidos, Consumo irregular (picos y valles): Usar forecasting para ajustar compras a demanda real, Evitar compras 'por si acaso' sin forecast que lo justifique, FIFO físico no respetado: Auditar almacén físico (lotes antiguos escondidos atrás?), Re-entrenar personal en FIFO importance",
"Tácticas de reducción: Promociones pre-expiration: Productos que caducan en 3-5 días → vender con descuento (mejor margen reducido que waste 100%), Donar antes de caducar: Contactar bancos de alimentos (aceptan productos 1-2 días antes de expiration), Tax benefits + RSC + zero waste, Transformar productos: Ingredientes próximos a caducar → usar en productos con shelf life corto (pan del día, producción para consumo inmediato)",
"Monitoreo continuo: Weekly waste review meeting (15 min), Meta: Reducir waste_rate 1% cada mes hasta alcanzar <5% objetivo"
]
}
],
"advancedFeatures": [
{
"feature": "AI-Powered Inventory Optimization (Roadmap)",
"description": "Machine Learning analizará patrones históricos para: Predecir consumo futuro por ingrediente (más preciso que promedios simples), Recomendar ajustes dinámicos de reorder_point según estacionalidad (verano vs invierno, festivos), Detectar anomalías (consumo inusual → posible theft, error de registro, nuevo patrón de demanda), Sugerir consolidación de proveedores (ingredientes similares de proveedores diferentes → negociar mejor precio con uno solo)."
},
{
"feature": "IoT Sensors Integration",
"description": "Integración con sensores de almacén: Temperature & Humidity Sensors: Monitoreo 24/7 de cámaras frías, almacenes, Alertas automáticas si temperatura fuera de rango (food safety), Weight Sensors en estanterías: Tracking automático de stock level sin registro manual, Detección de discrepancias en tiempo real (stock_sensor vs stock_sistema), RFID Tags en lotes: Escaneo automático al mover lotes (recepción, uso, transferencia), Trazabilidad automática sin intervención humana. Status: En roadmap, pilotos con clientes enterprise."
},
{
"feature": "Multi-Location Inventory (Enterprise)",
"description": "Para empresas con obrador central + sucursales: Stock unificado a nivel tenant con location dimension, Transfers automáticos: Obrador produce → auto-transfer a sucursales según forecast por sucursal, Dashboard consolidado: Stock total cross-location, Stock por location (drill-down), Transfer optimization: Sistema sugiere qué sucursal necesita reposición urgente, cuánto transferir desde obrador/otras sucursales. Disponible en plan Enterprise."
}
],
"conclusion": "El sistema de inventario es la columna vertebral operativa de BakeWise. Invierte tiempo en: 1) Configuración inicial correcta (umbrales realistas de reorder_point basados en consumo real + lead time), 2) Disciplina de registro (TODAS las entradas y salidas, sin excepciones), 3) Inventario físico regular (trimestral mínimo, mensual ideal para ingredientes críticos), 4) Análisis continuo (revisar waste, discrepancias, niveles de stock semanalmente). Con inventario bien gestionado, lograrás: Waste <5% (vs 15-25% sin sistema), Roturas de stock <2% (vs 10-20% sin sistema), Capital inmovilizado -30% (stock óptimo, no excesivo ni insuficiente), Compliance HACCP 100% (trazabilidad completa para auditorías). El ROI típico del módulo de inventario es 300-500% en el primer año solo por reducción de waste."
}
},
"posIntegration": {

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);
// NEW ARCHITECTURE: Check if SetupIntent verification is required
if (response && response.requires_action) {
// SetupIntent required - NO user created yet, NO tokens returned
// Store registration data for post-3DS completion
const pendingRegistrationData = {
email: userData.email,
password: userData.password,
full_name: userData.full_name,
setup_intent_id: response.setup_intent_id,
plan_id: response.plan_id,
payment_method_id: response.payment_method_id,
billing_interval: response.billing_interval,
coupon_code: response.coupon_code,
customer_id: response.customer_id,
payment_customer_id: response.payment_customer_id,
trial_period_days: response.trial_period_days,
client_secret: response.client_secret
};
// Store in session storage for post-3DS completion
sessionStorage.setItem('pending_registration_data', JSON.stringify(pendingRegistrationData));
set({
isLoading: false,
error: null,
pendingRegistrationData,
});
// Return the SetupIntent data for frontend to handle 3DS
return response;
}
// OLD FLOW: No SetupIntent required - user created and authenticated
if (response && response.access_token) {
// Set the auth tokens on the API client immediately
apiClient.setAuthToken(response.access_token);
@@ -223,6 +256,60 @@ export const useAuthStore = create<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: () => {
// Clear the auth tokens from API client
apiClient.setAuthToken(null);
@@ -446,6 +533,7 @@ export const useAuthActions = () => useAuthStore((state) => ({
login: state.login,
register: state.register,
registerWithSubscription: state.registerWithSubscription,
completeRegistrationAfterSetupIntent: state.completeRegistrationAfterSetupIntent,
logout: state.logout,
refreshAuth: state.refreshAuth,
updateUser: state.updateUser,