Add subcription feature 9
This commit is contained in:
@@ -35,7 +35,16 @@ server {
|
||||
# The frontend makes requests to /api which are routed by the ingress controller
|
||||
|
||||
# Static assets with aggressive caching (including source maps for debugging)
|
||||
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot|map)$ {
|
||||
location ~* ^/assets/.*\.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot|map)$ {
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, immutable";
|
||||
add_header Vary Accept-Encoding;
|
||||
access_log off;
|
||||
try_files $uri =404;
|
||||
}
|
||||
|
||||
# Also handle JS and CSS files anywhere in the structure (for dynamic imports)
|
||||
location ~* \.(js|css)$ {
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, immutable";
|
||||
add_header Vary Accept-Encoding;
|
||||
|
||||
@@ -252,8 +252,12 @@ export class SubscriptionService {
|
||||
return apiClient.get<PlanUpgradeValidation>(`/tenants/${tenantId}/subscription/validate-upgrade/${planKey}`);
|
||||
}
|
||||
|
||||
async upgradePlan(tenantId: string, planKey: string): Promise<PlanUpgradeResult> {
|
||||
return apiClient.post<PlanUpgradeResult>(`/tenants/${tenantId}/subscription/upgrade`, { new_plan: planKey });
|
||||
async upgradePlan(tenantId: string, planKey: string, billingCycle: BillingCycle = 'monthly'): Promise<PlanUpgradeResult> {
|
||||
// The backend expects new_plan and billing_cycle as query parameters
|
||||
return apiClient.post<PlanUpgradeResult>(
|
||||
`/tenants/${tenantId}/subscription/upgrade?new_plan=${planKey}&billing_cycle=${billingCycle}`,
|
||||
{}
|
||||
);
|
||||
}
|
||||
|
||||
async canAddLocation(tenantId: string): Promise<{ can_add: boolean; reason?: string; current_count?: number; max_allowed?: number }> {
|
||||
|
||||
@@ -261,11 +261,16 @@ export interface PlanUpgradeResult {
|
||||
success: boolean;
|
||||
message: string;
|
||||
new_plan: SubscriptionTier;
|
||||
effective_date: string;
|
||||
effective_date?: string;
|
||||
old_plan?: string;
|
||||
new_monthly_price?: number;
|
||||
validation?: any;
|
||||
requires_token_refresh?: boolean; // Backend signals that token should be refreshed
|
||||
// Trial handling fields
|
||||
is_trialing?: boolean;
|
||||
trial_ends_at?: string;
|
||||
stripe_updated?: boolean;
|
||||
trial_preserved?: boolean;
|
||||
}
|
||||
|
||||
export interface SubscriptionInvoice {
|
||||
|
||||
@@ -5,6 +5,13 @@ import { DialogModal } from '../../components/ui/DialogModal/DialogModal';
|
||||
import { subscriptionService } from '../../api';
|
||||
import { showToast } from '../../utils/toast';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { loadStripe, Stripe, StripeElements } from '@stripe/stripe-js';
|
||||
import {
|
||||
Elements,
|
||||
CardElement,
|
||||
useStripe as useStripeHook,
|
||||
useElements as useElementsHook
|
||||
} from '@stripe/react-stripe-js';
|
||||
|
||||
interface PaymentMethodUpdateModalProps {
|
||||
isOpen: boolean;
|
||||
@@ -24,6 +31,277 @@ interface PaymentMethodUpdateModalProps {
|
||||
}) => void;
|
||||
}
|
||||
|
||||
// Get Stripe publishable key from environment with proper fallback
|
||||
const getStripePublishableKey = (): string => {
|
||||
// Try runtime config first (for production)
|
||||
if (typeof window !== 'undefined' && (window as any).__RUNTIME_CONFIG__?.VITE_STRIPE_PUBLISHABLE_KEY) {
|
||||
return (window as any).__RUNTIME_CONFIG__.VITE_STRIPE_PUBLISHABLE_KEY;
|
||||
}
|
||||
|
||||
// Try build-time env variable
|
||||
if (import.meta.env.VITE_STRIPE_PUBLISHABLE_KEY) {
|
||||
return import.meta.env.VITE_STRIPE_PUBLISHABLE_KEY;
|
||||
}
|
||||
|
||||
// Fallback for development - should not be used in production
|
||||
console.warn('Stripe publishable key not found in environment. Using test key for development.');
|
||||
return 'pk_test_51QuxKyIzCdnBmAVTGM8fvXYkItrBUILz6lHYwhAva6ZAH1HRi0e8zDRgZ4X3faN0zEABp5RHjCVBmMJL3aKXbaC200fFrSNnPl';
|
||||
};
|
||||
|
||||
// Create Stripe promise for Elements provider
|
||||
const stripePromise = loadStripe(getStripePublishableKey(), {
|
||||
betas: ['elements_v2'],
|
||||
locale: 'auto',
|
||||
});
|
||||
|
||||
/**
|
||||
* Stripe Card Form Component
|
||||
* This component handles the actual Stripe card input and submission
|
||||
*/
|
||||
const StripeCardForm = ({
|
||||
tenantId,
|
||||
onSuccess,
|
||||
onError,
|
||||
onLoading
|
||||
}: {
|
||||
tenantId: string;
|
||||
onSuccess: (paymentMethod: any) => void;
|
||||
onError: (error: string) => void;
|
||||
onLoading: (loading: boolean) => void;
|
||||
}) => {
|
||||
const stripe = useStripeHook();
|
||||
const elements = useElementsHook();
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [authenticating, setAuthenticating] = useState(false);
|
||||
|
||||
// Check if elements are ready
|
||||
const [elementsReady, setElementsReady] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (elements) {
|
||||
const checkElementsReady = async () => {
|
||||
try {
|
||||
// Wait a moment for elements to be fully ready
|
||||
await new Promise(resolve => setTimeout(resolve, 50));
|
||||
setElementsReady(true);
|
||||
} catch (err) {
|
||||
console.error('Elements not ready:', err);
|
||||
setError('Payment form not ready. Please try again.');
|
||||
}
|
||||
};
|
||||
|
||||
checkElementsReady();
|
||||
}
|
||||
}, [elements]);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!stripe || !elements || !elementsReady) {
|
||||
setError('Payment processor not loaded');
|
||||
onError('Payment processor not loaded');
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
onLoading(true);
|
||||
|
||||
try {
|
||||
// Trigger form validation and wallet collection
|
||||
const { error: submitError } = await elements.submit();
|
||||
|
||||
if (submitError) {
|
||||
setError(submitError.message || 'Failed to validate payment information');
|
||||
setLoading(false);
|
||||
onLoading(false);
|
||||
onError(submitError.message || 'Failed to validate payment information');
|
||||
return;
|
||||
}
|
||||
|
||||
// Create payment method using Stripe Elements
|
||||
const cardElement = elements.getElement(CardElement);
|
||||
const { paymentMethod, error: stripeError } = await stripe.createPaymentMethod({
|
||||
type: 'card',
|
||||
card: cardElement,
|
||||
});
|
||||
|
||||
if (stripeError) {
|
||||
setError(stripeError.message || 'Failed to create payment method');
|
||||
setLoading(false);
|
||||
onLoading(false);
|
||||
onError(stripeError.message || 'Failed to create payment method');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!paymentMethod || !paymentMethod.id) {
|
||||
setError('No payment method created');
|
||||
setLoading(false);
|
||||
onLoading(false);
|
||||
onError('No payment method created');
|
||||
return;
|
||||
}
|
||||
|
||||
// Call backend to update payment method
|
||||
const result = await subscriptionService.updatePaymentMethod(tenantId, paymentMethod.id);
|
||||
|
||||
// Handle 3D Secure authentication if required
|
||||
if (result.requires_action && result.client_secret) {
|
||||
setAuthenticating(true);
|
||||
setLoading(false);
|
||||
|
||||
try {
|
||||
// Handle 3D Secure authentication
|
||||
const { error: confirmError, setupIntent } = await stripe.confirmCardSetup(result.client_secret);
|
||||
|
||||
if (confirmError) {
|
||||
setError(confirmError.message || '3D Secure authentication failed');
|
||||
setAuthenticating(false);
|
||||
onLoading(false);
|
||||
onError(confirmError.message || '3D Secure authentication failed');
|
||||
return;
|
||||
}
|
||||
|
||||
if (setupIntent && setupIntent.status === 'succeeded') {
|
||||
onSuccess({
|
||||
brand: result.brand,
|
||||
last4: result.last4,
|
||||
exp_month: result.exp_month,
|
||||
exp_year: result.exp_year,
|
||||
});
|
||||
} else {
|
||||
setError('3D Secure authentication completed but payment method not confirmed');
|
||||
setAuthenticating(false);
|
||||
onLoading(false);
|
||||
onError('3D Secure authentication completed but payment method not confirmed');
|
||||
}
|
||||
} catch (authError) {
|
||||
console.error('Error during 3D Secure authentication:', authError);
|
||||
const errorMessage = authError instanceof Error
|
||||
? authError.message
|
||||
: '3D Secure authentication error. Please try again.';
|
||||
setError(errorMessage);
|
||||
setAuthenticating(false);
|
||||
onLoading(false);
|
||||
onError(errorMessage);
|
||||
}
|
||||
} else if (result.success) {
|
||||
// No authentication required
|
||||
onSuccess({
|
||||
brand: result.brand,
|
||||
last4: result.last4,
|
||||
exp_month: result.exp_month,
|
||||
exp_year: result.exp_year,
|
||||
});
|
||||
} else {
|
||||
// Handle different payment intent statuses
|
||||
let errorMessage = result.message || 'Failed to update payment method';
|
||||
if (result.payment_intent_status === 'requires_payment_method') {
|
||||
errorMessage = 'Your card was declined. Please try a different payment method.';
|
||||
} else if (result.payment_intent_status === 'requires_action') {
|
||||
errorMessage = 'Authentication required but client secret missing. Please try again.';
|
||||
} else if (result.payment_intent_status === 'processing') {
|
||||
errorMessage = 'Payment is processing. Please wait a few minutes and try again.';
|
||||
} else if (result.payment_intent_status === 'canceled') {
|
||||
errorMessage = 'Payment was canceled. Please try again.';
|
||||
}
|
||||
|
||||
setError(errorMessage);
|
||||
setLoading(false);
|
||||
onLoading(false);
|
||||
onError(errorMessage);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error updating payment method:', err);
|
||||
const errorMessage = err instanceof Error
|
||||
? err.message
|
||||
: 'An unexpected error occurred while updating payment method.';
|
||||
setError(errorMessage);
|
||||
setLoading(false);
|
||||
onLoading(false);
|
||||
onError(errorMessage);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-[var(--text-primary)] mb-2">
|
||||
Nueva Tarjeta
|
||||
</label>
|
||||
<div className="p-3 border border-[var(--border-primary)] rounded-lg bg-[var(--bg-primary)] min-h-[50px]">
|
||||
{!elementsReady ? (
|
||||
<div className="flex items-center gap-2 text-[var(--text-secondary)] h-[50px]">
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
<span>Preparando formulario de pago...</span>
|
||||
</div>
|
||||
) : (
|
||||
<CardElement
|
||||
options={
|
||||
{
|
||||
style: {
|
||||
base: {
|
||||
fontSize: '16px',
|
||||
color: '#32325d',
|
||||
'::placeholder': {
|
||||
color: '#aab7c4',
|
||||
},
|
||||
},
|
||||
invalid: {
|
||||
color: '#fa755a',
|
||||
iconColor: '#fa755a',
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 3D Secure Authentication Status */}
|
||||
{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 Display */}
|
||||
{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>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="flex gap-3 justify-end">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
disabled={loading || authenticating}
|
||||
>
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="primary"
|
||||
disabled={loading || authenticating}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
{(loading || authenticating) && <Loader2 className="w-4 h-4 animate-spin" />}
|
||||
{authenticating ? 'Autenticando...' : loading ? 'Procesando...' : 'Actualizar Método de Pago'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Main Payment Method Update Modal Component
|
||||
*/
|
||||
export const PaymentMethodUpdateModal: React.FC<PaymentMethodUpdateModalProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
@@ -33,271 +311,62 @@ 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);
|
||||
const [cardElement, setCardElement] = useState<any>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [success, setSuccess] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [stripeLoaded, setStripeLoaded] = useState(false);
|
||||
const [stripeError, setStripeError] = useState<string | null>(null);
|
||||
|
||||
// Load Stripe.js dynamically
|
||||
const handleSuccess = (paymentMethod: any) => {
|
||||
setSuccess(true);
|
||||
showToast.success('Payment method updated successfully');
|
||||
onPaymentMethodUpdated(paymentMethod);
|
||||
|
||||
// Close modal after a brief delay to show success message
|
||||
setTimeout(() => {
|
||||
handleClose();
|
||||
}, 2000);
|
||||
};
|
||||
|
||||
const handleError = (errorMessage: string) => {
|
||||
setError(errorMessage);
|
||||
};
|
||||
|
||||
const handleLoading = (isLoading: boolean) => {
|
||||
setLoading(isLoading);
|
||||
};
|
||||
|
||||
// Track Stripe loading status
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
const loadStripe = async () => {
|
||||
setStripeLoaded(false);
|
||||
setStripeError(null);
|
||||
|
||||
// Check if Stripe is already loaded
|
||||
const checkStripeLoaded = async () => {
|
||||
try {
|
||||
// Get Stripe publishable key from runtime config or build-time env
|
||||
const getStripePublishableKey = () => {
|
||||
if (typeof window !== 'undefined' && (window as any).__RUNTIME_CONFIG__?.VITE_STRIPE_PUBLISHABLE_KEY) {
|
||||
return (window as any).__RUNTIME_CONFIG__.VITE_STRIPE_PUBLISHABLE_KEY;
|
||||
}
|
||||
return import.meta.env.VITE_STRIPE_PUBLISHABLE_KEY;
|
||||
};
|
||||
|
||||
const stripeKey = getStripePublishableKey();
|
||||
if (!stripeKey) {
|
||||
throw new Error('Stripe publishable key not configured');
|
||||
// Give Stripe a moment to load
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
|
||||
// Check if the Stripe promise resolves successfully
|
||||
const stripe = await stripePromise;
|
||||
if (stripe) {
|
||||
setStripeLoaded(true);
|
||||
}
|
||||
|
||||
// Load Stripe.js from CDN
|
||||
const stripeScript = document.createElement('script');
|
||||
stripeScript.src = 'https://js.stripe.com/v3/';
|
||||
stripeScript.async = true;
|
||||
stripeScript.onload = () => {
|
||||
const stripeInstance = (window as any).Stripe(stripeKey);
|
||||
setStripe(stripeInstance);
|
||||
const elementsInstance = stripeInstance.elements();
|
||||
setElements(elementsInstance);
|
||||
};
|
||||
document.body.appendChild(stripeScript);
|
||||
} catch (err) {
|
||||
console.error('Failed to load Stripe:', err);
|
||||
setError('Failed to load payment processor');
|
||||
setStripeError('Failed to load payment processor. Please check your connection.');
|
||||
}
|
||||
};
|
||||
|
||||
loadStripe();
|
||||
|
||||
checkStripeLoaded();
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
// Create card element when Stripe and elements are loaded
|
||||
useEffect(() => {
|
||||
if (elements && !cardElement) {
|
||||
const card = elements.create('card', {
|
||||
style: {
|
||||
base: {
|
||||
fontSize: '16px',
|
||||
color: '#32325d',
|
||||
'::placeholder': {
|
||||
color: '#aab7c4',
|
||||
},
|
||||
},
|
||||
invalid: {
|
||||
color: '#fa755a',
|
||||
iconColor: '#fa755a',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Mount the card element
|
||||
const cardElementContainer = document.getElementById('card-element');
|
||||
if (cardElementContainer) {
|
||||
card.mount(cardElementContainer);
|
||||
setCardElement(card);
|
||||
}
|
||||
}
|
||||
}, [elements, cardElement]);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!stripe || !cardElement) {
|
||||
setError('Payment processor not loaded');
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
setSuccess(false);
|
||||
|
||||
try {
|
||||
// Submit the card element to validate all inputs
|
||||
const { error: submitError } = await elements.submit();
|
||||
|
||||
if (submitError) {
|
||||
setError(submitError.message || 'Failed to validate payment information');
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Create payment method using Stripe Elements
|
||||
const { paymentMethod, error: stripeError } = await stripe.createPaymentMethod({
|
||||
type: 'card',
|
||||
card: cardElement,
|
||||
});
|
||||
|
||||
if (stripeError) {
|
||||
setError(stripeError.message || 'Failed to create payment method');
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!paymentMethod || !paymentMethod.id) {
|
||||
setError('No payment method created');
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Call backend to update payment method
|
||||
const result = await subscriptionService.updatePaymentMethod(tenantId, paymentMethod.id);
|
||||
|
||||
// Log 3DS requirement status
|
||||
if (result.requires_action) {
|
||||
console.log('3DS authentication required for payment method update', {
|
||||
payment_method_id: paymentMethod.id,
|
||||
setup_intent_id: result.payment_intent_id,
|
||||
action_required: result.requires_action
|
||||
});
|
||||
}
|
||||
|
||||
if (result.success) {
|
||||
// Check if 3D Secure authentication is required
|
||||
if (result.requires_action && result.client_secret) {
|
||||
setAuthenticating(true);
|
||||
setLoading(false);
|
||||
setError(null);
|
||||
|
||||
console.log('Starting 3DS authentication process', {
|
||||
client_secret: result.client_secret.substring(0, 20) + '...', // Log partial secret for security
|
||||
payment_method_id: paymentMethod.id,
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
|
||||
try {
|
||||
// Handle 3D Secure authentication
|
||||
const { error: confirmError, setupIntent } = await stripe.confirmCardSetup(result.client_secret);
|
||||
|
||||
console.log('3DS authentication completed', {
|
||||
setup_intent_status: setupIntent?.status,
|
||||
error: confirmError ? confirmError.message : 'none',
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
|
||||
if (confirmError) {
|
||||
// Handle specific 3D Secure error types
|
||||
if (confirmError.type === 'card_error') {
|
||||
setError(confirmError.message || 'Card authentication failed');
|
||||
} else if (confirmError.type === 'validation_error') {
|
||||
setError('Invalid authentication request');
|
||||
} else if (confirmError.type === 'api_error') {
|
||||
setError('Authentication service unavailable');
|
||||
} else {
|
||||
setError(confirmError.message || '3D Secure authentication failed');
|
||||
}
|
||||
setAuthenticating(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Check setup intent status after authentication
|
||||
if (setupIntent && setupIntent.status === 'succeeded') {
|
||||
setSuccess(true);
|
||||
showToast.success('Payment method updated and authenticated successfully');
|
||||
|
||||
console.log('3DS authentication successful', {
|
||||
payment_method_id: paymentMethod.id,
|
||||
setup_intent_id: setupIntent.id,
|
||||
setup_intent_status: setupIntent.status,
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
|
||||
// Notify parent component about the update
|
||||
onPaymentMethodUpdated({
|
||||
brand: result.brand,
|
||||
last4: result.last4,
|
||||
exp_month: result.exp_month,
|
||||
exp_year: result.exp_year,
|
||||
});
|
||||
|
||||
// Close modal after a brief delay to show success message
|
||||
setTimeout(() => {
|
||||
onClose();
|
||||
}, 2000);
|
||||
} else {
|
||||
console.log('3DS authentication completed with non-success status', {
|
||||
setup_intent_status: setupIntent?.status,
|
||||
payment_method_id: paymentMethod.id,
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
|
||||
setError('3D Secure authentication completed but payment method not confirmed');
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
} catch (authError) {
|
||||
console.error('Error during 3D Secure authentication:', authError);
|
||||
|
||||
// Enhanced error handling for 3DS failures
|
||||
if (authError instanceof Error) {
|
||||
if (authError.message.includes('canceled') || authError.message.includes('user closed')) {
|
||||
setError('3D Secure authentication was canceled. You can try again with the same card.');
|
||||
} else if (authError.message.includes('failed') || authError.message.includes('declined')) {
|
||||
setError('3D Secure authentication failed. Please try again or use a different card.');
|
||||
} else if (authError.message.includes('timeout') || authError.message.includes('network')) {
|
||||
setError('Network error during 3D Secure authentication. Please check your connection and try again.');
|
||||
} else {
|
||||
setError(`3D Secure authentication error: ${authError.message}`);
|
||||
}
|
||||
} else {
|
||||
setError('3D Secure authentication error. Please try again.');
|
||||
}
|
||||
|
||||
setAuthenticating(false);
|
||||
}
|
||||
} else {
|
||||
// No authentication required
|
||||
setSuccess(true);
|
||||
showToast.success(result.message);
|
||||
|
||||
// Notify parent component about the update
|
||||
onPaymentMethodUpdated({
|
||||
brand: result.brand,
|
||||
last4: result.last4,
|
||||
exp_month: result.exp_month,
|
||||
exp_year: result.exp_year,
|
||||
});
|
||||
|
||||
// Close modal after a brief delay to show success message
|
||||
setTimeout(() => {
|
||||
onClose();
|
||||
}, 2000);
|
||||
}
|
||||
} else {
|
||||
// Handle different payment intent statuses
|
||||
if (result.payment_intent_status === 'requires_payment_method') {
|
||||
setError('Your card was declined. Please try a different payment method.');
|
||||
} else if (result.payment_intent_status === 'requires_action') {
|
||||
setError('Authentication required but client secret missing. Please try again.');
|
||||
} else if (result.payment_intent_status === 'processing') {
|
||||
setError('Payment is processing. Please wait a few minutes and try again.');
|
||||
} else if (result.payment_intent_status === 'canceled') {
|
||||
setError('Payment was canceled. Please try again.');
|
||||
} else {
|
||||
setError(result.message || 'Failed to update payment method');
|
||||
}
|
||||
}
|
||||
|
||||
} catch (err) {
|
||||
console.error('Error updating payment method:', err);
|
||||
setError(err instanceof Error ? err.message : 'An unexpected error occurred');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
setError(null);
|
||||
setSuccess(false);
|
||||
setPaymentMethodId('');
|
||||
setStripeLoaded(false);
|
||||
setStripeError(null);
|
||||
onClose();
|
||||
};
|
||||
|
||||
@@ -306,73 +375,66 @@ export const PaymentMethodUpdateModal: React.FC<PaymentMethodUpdateModalProps> =
|
||||
isOpen={isOpen}
|
||||
onClose={handleClose}
|
||||
title="Actualizar Método de Pago"
|
||||
message={null}
|
||||
type="custom"
|
||||
size="lg"
|
||||
>
|
||||
<div className="space-y-6">
|
||||
{/* Current Payment Method Info */}
|
||||
{currentPaymentMethod && (
|
||||
<div className="p-4 bg-[var(--bg-secondary)] rounded-lg border border-[var(--border-secondary)]">
|
||||
<h4 className="font-medium text-[var(--text-primary)] mb-2">Método de Pago Actual</h4>
|
||||
<div className="flex items-center gap-3">
|
||||
<CreditCard className="w-6 h-6 text-blue-500" />
|
||||
<div>
|
||||
<p className="text-[var(--text-primary)]">
|
||||
{currentPaymentMethod.brand} terminando en {currentPaymentMethod.last4}
|
||||
</p>
|
||||
{currentPaymentMethod.exp_month && currentPaymentMethod.exp_year && (
|
||||
<p className="text-sm text-[var(--text-secondary)]">
|
||||
Expira: {currentPaymentMethod.exp_month}/{currentPaymentMethod.exp_year}
|
||||
size="md"
|
||||
message={
|
||||
<div className="space-y-6">
|
||||
{/* Current Payment Method Info */}
|
||||
{currentPaymentMethod && (
|
||||
<div className="p-4 bg-[var(--bg-secondary)] rounded-lg border border-[var(--border-secondary)]">
|
||||
<h4 className="font-medium text-[var(--text-primary)] mb-2">Método de Pago Actual</h4>
|
||||
<div className="flex items-center gap-3">
|
||||
<CreditCard className="w-6 h-6 text-blue-500" />
|
||||
<div>
|
||||
<p className="text-[var(--text-primary)]">
|
||||
{currentPaymentMethod.brand} terminando en {currentPaymentMethod.last4}
|
||||
</p>
|
||||
)}
|
||||
{currentPaymentMethod.exp_month && currentPaymentMethod.exp_year && (
|
||||
<p className="text-sm text-[var(--text-secondary)]">
|
||||
Expira: {currentPaymentMethod.exp_month}/{currentPaymentMethod.exp_year}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
)}
|
||||
|
||||
{/* Payment Form */}
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-[var(--text-primary)] mb-2">
|
||||
Nueva Tarjeta
|
||||
</label>
|
||||
<div id="card-element" className="p-3 border border-[var(--border-primary)] rounded-lg bg-[var(--bg-primary)]">
|
||||
{/* Stripe card element will be mounted here */}
|
||||
{!cardElement && !error && (
|
||||
<div className="flex items-center gap-2 text-[var(--text-secondary)]">
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
<span>Cargando procesador de pagos...</span>
|
||||
</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>
|
||||
{/* Stripe Elements Provider with Card Form */}
|
||||
{!stripeLoaded && !stripeError && (
|
||||
<div className="p-4 bg-blue-500/5 border border-blue-500/20 rounded-lg text-center">
|
||||
<Loader2 className="w-6 h-6 animate-spin mx-auto mb-2 text-blue-500" />
|
||||
<span className="text-[var(--text-secondary)]">Cargando procesador de pagos seguro...</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>
|
||||
)}
|
||||
{stripeError && (
|
||||
<div className="p-4 bg-red-500/10 border border-red-500/20 rounded-lg text-center text-red-500">
|
||||
<AlertCircle className="w-6 h-6 mx-auto mb-2" />
|
||||
<span className="text-sm">Error loading payment processor. Please refresh the page.</span>
|
||||
<Button
|
||||
type="button"
|
||||
variant="text"
|
||||
size="sm"
|
||||
onClick={() => window.location.reload()}
|
||||
className="mt-2 text-red-600 hover:text-red-800"
|
||||
>
|
||||
Refresh Page
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{stripeLoaded && stripePromise && (
|
||||
<Elements stripe={stripePromise}>
|
||||
<StripeCardForm
|
||||
tenantId={tenantId}
|
||||
onSuccess={handleSuccess}
|
||||
onError={handleError}
|
||||
onLoading={handleLoading}
|
||||
/>
|
||||
</Elements>
|
||||
)}
|
||||
|
||||
{/* Success Message */}
|
||||
{success && (
|
||||
<div className="p-3 bg-green-500/10 border border-green-500/20 rounded-lg flex items-center gap-2 text-green-500">
|
||||
<CheckCircle className="w-4 h-4" />
|
||||
@@ -380,35 +442,23 @@ export const PaymentMethodUpdateModal: React.FC<PaymentMethodUpdateModalProps> =
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex gap-3 justify-end">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={handleClose}
|
||||
disabled={loading || authenticating}
|
||||
>
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="primary"
|
||||
disabled={loading || authenticating || !cardElement}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
{(loading || authenticating) && <Loader2 className="w-4 h-4 animate-spin" />}
|
||||
{authenticating ? 'Autenticando...' : loading ? 'Procesando...' : 'Actualizar Método de Pago'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
{/* Error Message (from modal level) */}
|
||||
{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>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Security Info */}
|
||||
<div className="p-3 bg-blue-500/5 border border-blue-500/20 rounded-lg text-sm text-[var(--text-secondary)]">
|
||||
<p className="flex items-center gap-2">
|
||||
<CreditCard className="w-4 h-4 text-blue-500" />
|
||||
<span>Tus datos de pago están protegidos y se procesan de forma segura.</span>
|
||||
</p>
|
||||
{/* Security Info */}
|
||||
<div className="p-3 bg-blue-500/5 border border-blue-500/20 rounded-lg text-sm text-[var(--text-secondary)]">
|
||||
<p className="flex items-center gap-2">
|
||||
<CreditCard className="w-4 h-4 text-blue-500" />
|
||||
<span>Tus datos de pago están protegidos y se procesan de forma segura.</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</DialogModal>
|
||||
}
|
||||
/>
|
||||
);
|
||||
};
|
||||
};
|
||||
|
||||
@@ -64,6 +64,7 @@ interface CommunicationPreferencesProps {
|
||||
onSave: (preferences: NotificationPreferences) => Promise<void>;
|
||||
onReset: () => void;
|
||||
hasChanges: boolean;
|
||||
onPreferencesChange?: (preferences: NotificationPreferences) => void;
|
||||
}
|
||||
|
||||
const CommunicationPreferences: React.FC<CommunicationPreferencesProps> = ({
|
||||
@@ -144,10 +145,16 @@ const CommunicationPreferences: React.FC<CommunicationPreferencesProps> = ({
|
||||
];
|
||||
|
||||
const handlePreferenceChange = (key: keyof NotificationPreferences, value: any) => {
|
||||
setPreferences(prev => ({
|
||||
...prev,
|
||||
const newPreferences = {
|
||||
...preferences,
|
||||
[key]: value
|
||||
}));
|
||||
};
|
||||
setPreferences(newPreferences);
|
||||
|
||||
// Notify parent component about changes
|
||||
if (onPreferencesChange) {
|
||||
onPreferencesChange(newPreferences);
|
||||
}
|
||||
};
|
||||
|
||||
const handleContactChange = (field: 'email' | 'phone', value: string) => {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -335,6 +335,9 @@ const SubscriptionPageRedesign: React.FC = () => {
|
||||
exp_year?: number;
|
||||
} | null>(null);
|
||||
|
||||
// Billing cycle state (monthly/yearly)
|
||||
const [billingCycle, setBillingCycle] = useState<'monthly' | 'yearly'>('monthly');
|
||||
|
||||
// Section visibility states
|
||||
const [showUsage, setShowUsage] = useState(false);
|
||||
const [showBilling, setShowBilling] = useState(false);
|
||||
@@ -439,6 +442,10 @@ const SubscriptionPageRedesign: React.FC = () => {
|
||||
|
||||
setUsageSummary(usage);
|
||||
setAvailablePlans(plans);
|
||||
// Initialize billing cycle from current subscription
|
||||
if (usage.billing_cycle) {
|
||||
setBillingCycle(usage.billing_cycle);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading subscription data:', error);
|
||||
showToast.error(
|
||||
@@ -470,6 +477,7 @@ const SubscriptionPageRedesign: React.FC = () => {
|
||||
try {
|
||||
setUpgrading(true);
|
||||
|
||||
// Step 1: Validate the upgrade
|
||||
const validation = await subscriptionService.validatePlanUpgrade(
|
||||
tenantId,
|
||||
selectedPlan
|
||||
@@ -480,21 +488,46 @@ const SubscriptionPageRedesign: React.FC = () => {
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await subscriptionService.upgradePlan(tenantId, selectedPlan);
|
||||
// Step 2: Execute the upgrade (uses current billing cycle)
|
||||
const result = await subscriptionService.upgradePlan(
|
||||
tenantId,
|
||||
selectedPlan,
|
||||
billingCycle // Pass the currently selected billing cycle
|
||||
);
|
||||
|
||||
if (result.success) {
|
||||
showToast.success(result.message);
|
||||
// Show appropriate success message based on trial status
|
||||
if (result.is_trialing && result.trial_preserved) {
|
||||
showToast.success(
|
||||
`¡Plan actualizado a ${result.new_plan}! Tu período de prueba continúa hasta ${
|
||||
result.trial_ends_at ? new Date(result.trial_ends_at).toLocaleDateString('es-ES') : 'su fecha original'
|
||||
}. Al finalizar, se aplicará el precio del nuevo plan.`
|
||||
);
|
||||
} else {
|
||||
showToast.success(result.message || `¡Plan actualizado exitosamente a ${result.new_plan}!`);
|
||||
}
|
||||
|
||||
// Step 3: Invalidate caches to refresh UI
|
||||
subscriptionService.invalidateCache();
|
||||
|
||||
// Step 4: Refresh subscription data from server
|
||||
if (result.requires_token_refresh) {
|
||||
// Force a fresh fetch of subscription data
|
||||
await subscriptionService.getUsageSummary(tenantId).catch(() => {});
|
||||
}
|
||||
|
||||
// Step 5: Invalidate React Query caches
|
||||
await queryClient.invalidateQueries({ queryKey: ['subscription-usage'] });
|
||||
await queryClient.invalidateQueries({ queryKey: ['tenant'] });
|
||||
await queryClient.invalidateQueries({ queryKey: ['subscription'] });
|
||||
|
||||
// Step 6: Notify other components about the change
|
||||
notifySubscriptionChanged();
|
||||
|
||||
// Step 7: Reload subscription data for this page
|
||||
await loadSubscriptionData();
|
||||
|
||||
// Step 8: Close dialog and reset state
|
||||
setUpgradeDialogOpen(false);
|
||||
setSelectedPlan('');
|
||||
} else {
|
||||
@@ -502,7 +535,8 @@ const SubscriptionPageRedesign: React.FC = () => {
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error upgrading plan:', error);
|
||||
showToast.error('Error al procesar el cambio de plan');
|
||||
const errorMessage = error instanceof Error ? error.message : 'Error al procesar el cambio de plan';
|
||||
showToast.error(errorMessage);
|
||||
} finally {
|
||||
setUpgrading(false);
|
||||
}
|
||||
@@ -647,9 +681,9 @@ const SubscriptionPageRedesign: React.FC = () => {
|
||||
// Determine if this is a pilot subscription based on characteristics
|
||||
// Pilot subscriptions have extended trial periods (typically 90 days from PILOT2025 coupon)
|
||||
// compared to regular trials (typically 14 days), so we check for longer trial periods
|
||||
const isPilotSubscription = usageSummary.status === 'trialing' &&
|
||||
const isPilotSubscription = (usageSummary.status === 'trialing' ||
|
||||
(usageSummary.trial_ends_at && new Date(usageSummary.trial_ends_at) > new Date())) &&
|
||||
usageSummary.trial_ends_at &&
|
||||
new Date(usageSummary.trial_ends_at) > new Date() &&
|
||||
// Check if trial period is longer than typical trial (e.g., > 60 days indicates pilot)
|
||||
(new Date(usageSummary.trial_ends_at).getTime() - new Date().getTime()) > (60 * 24 * 60 * 60 * 1000); // 60+ days
|
||||
|
||||
@@ -726,6 +760,10 @@ const SubscriptionPageRedesign: React.FC = () => {
|
||||
onToggle={() => setShowUsage(!showUsage)}
|
||||
showAlert={hasHighUsageMetrics}
|
||||
>
|
||||
|
||||
{/* Spacing between content blocks */}
|
||||
<div className="h-6"></div>
|
||||
|
||||
<div className="space-y-6">
|
||||
{/* Team & Organization */}
|
||||
<div>
|
||||
@@ -886,6 +924,10 @@ const SubscriptionPageRedesign: React.FC = () => {
|
||||
isOpen={showBilling}
|
||||
onToggle={() => setShowBilling(!showBilling)}
|
||||
>
|
||||
|
||||
{/* Spacing between content blocks */}
|
||||
<div className="h-6"></div>
|
||||
|
||||
<div className="space-y-6">
|
||||
{/* Payment Method */}
|
||||
<div className="p-4 bg-[var(--bg-secondary)] rounded-lg border border-[var(--border-secondary)]">
|
||||
@@ -931,8 +973,21 @@ const SubscriptionPageRedesign: React.FC = () => {
|
||||
<div className="p-4 bg-[var(--bg-secondary)] rounded-full">
|
||||
<Download className="w-8 h-8 text-[var(--text-tertiary)]" />
|
||||
</div>
|
||||
<p className="text-[var(--text-secondary)]">No hay facturas disponibles</p>
|
||||
<p className="text-sm text-[var(--text-tertiary)]">Las facturas aparecerán aquí una vez realizados los pagos</p>
|
||||
{(usageSummary?.status === 'trialing' ||
|
||||
(usageSummary?.trial_ends_at && new Date(usageSummary.trial_ends_at) > new Date())) ? (
|
||||
<>
|
||||
<p className="text-[var(--text-secondary)]">Periodo de prueba activo</p>
|
||||
<p className="text-sm text-[var(--text-tertiary)]">
|
||||
No hay facturas durante el periodo de prueba. Las facturas aparecerán aquí
|
||||
una vez finalice el periodo de prueba el {usageSummary?.trial_ends_at ? new Date(usageSummary.trial_ends_at).toLocaleDateString('es-ES') : 'próximamente'}.
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<p className="text-[var(--text-secondary)]">No hay facturas disponibles</p>
|
||||
<p className="text-sm text-[var(--text-tertiary)]">Las facturas aparecerán aquí una vez realizados los pagos</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
@@ -951,21 +1006,32 @@ const SubscriptionPageRedesign: React.FC = () => {
|
||||
{Array.isArray(invoices) && invoices.slice(0, 5).map((invoice) => (
|
||||
<tr key={invoice.id} className="border-b border-[var(--border-color)] hover:bg-[var(--bg-secondary)] transition-colors">
|
||||
<td className="py-3 px-4 text-[var(--text-primary)] font-medium">
|
||||
{new Date(invoice.date).toLocaleDateString('es-ES', {
|
||||
{invoice.created ? new Date(invoice.created * 1000).toLocaleDateString('es-ES', {
|
||||
day: '2-digit',
|
||||
month: 'short',
|
||||
year: 'numeric'
|
||||
})}
|
||||
}) : 'N/A'}
|
||||
</td>
|
||||
<td className="py-3 px-4 text-[var(--text-primary)]">
|
||||
{invoice.description || 'Suscripción'}
|
||||
{invoice.trial ? (
|
||||
<span className="flex items-center gap-1">
|
||||
<span className="text-green-500">●</span>
|
||||
Periodo de prueba
|
||||
</span>
|
||||
) : (invoice.description || 'Suscripción')}
|
||||
</td>
|
||||
<td className="py-3 px-4 text-[var(--text-primary)] font-semibold text-right">
|
||||
{subscriptionService.formatPrice(invoice.amount)}
|
||||
{invoice.amount_due !== undefined ? (
|
||||
invoice.trial ? (
|
||||
<span className="text-green-500">0,00 €</span>
|
||||
) : (
|
||||
subscriptionService.formatPrice(invoice.amount_due)
|
||||
)
|
||||
) : 'N/A'}
|
||||
</td>
|
||||
<td className="py-3 px-4 text-center">
|
||||
<Badge variant={invoice.status === 'paid' ? 'success' : invoice.status === 'open' ? 'warning' : 'default'}>
|
||||
{invoice.status === 'paid' ? 'Pagada' : invoice.status === 'open' ? 'Pendiente' : invoice.status}
|
||||
<Badge variant={invoice.trial ? 'success' : invoice.status === 'paid' ? 'success' : invoice.status === 'open' ? 'warning' : 'default'}>
|
||||
{invoice.trial ? 'Prueba' : invoice.status === 'paid' ? 'Pagada' : invoice.status === 'open' ? 'Pendiente' : invoice.status}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="py-3 px-4 text-center">
|
||||
@@ -977,7 +1043,7 @@ const SubscriptionPageRedesign: React.FC = () => {
|
||||
className="flex items-center gap-2 mx-auto"
|
||||
>
|
||||
<Download className="w-4 h-4" />
|
||||
{invoice.invoice_pdf ? 'PDF' : 'Ver'}
|
||||
{invoice.invoice_pdf ? 'PDF' : invoice.hosted_invoice_url ? 'Ver' : 'Descargar'}
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -995,23 +1061,7 @@ const SubscriptionPageRedesign: React.FC = () => {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Support Contact */}
|
||||
<div className="p-4 bg-blue-500/5 border border-blue-500/20 rounded-lg">
|
||||
<div className="flex items-start gap-3">
|
||||
<Activity className="w-5 h-5 text-blue-500 flex-shrink-0 mt-0.5" />
|
||||
<div>
|
||||
<p className="text-sm text-[var(--text-primary)] font-medium mb-1">
|
||||
¿Preguntas sobre tu factura?
|
||||
</p>
|
||||
<p className="text-sm text-[var(--text-secondary)]">
|
||||
Contacta a nuestro equipo de soporte en{' '}
|
||||
<a href="mailto:support@bakery-ia.com" className="text-blue-500 hover:text-white hover:bg-blue-500 px-2 py-0.5 rounded transition-all duration-200 no-underline">
|
||||
support@bakery-ia.com
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</CollapsibleSection>
|
||||
|
||||
@@ -1022,6 +1072,10 @@ const SubscriptionPageRedesign: React.FC = () => {
|
||||
isOpen={showPlans}
|
||||
onToggle={() => setShowPlans(!showPlans)}
|
||||
>
|
||||
|
||||
{/* Spacing between content blocks */}
|
||||
<div className="h-6"></div>
|
||||
|
||||
<div className="space-y-6">
|
||||
{/* Available Plans */}
|
||||
<div>
|
||||
|
||||
Reference in New Issue
Block a user