Support subcription payments
This commit is contained in:
25
frontend/package-lock.json
generated
25
frontend/package-lock.json
generated
@@ -16,6 +16,8 @@
|
||||
"@radix-ui/react-switch": "^1.0.3",
|
||||
"@radix-ui/react-tabs": "^1.0.4",
|
||||
"@radix-ui/react-tooltip": "^1.0.7",
|
||||
"@stripe/react-stripe-js": "^2.7.3",
|
||||
"@stripe/stripe-js": "^3.0.10",
|
||||
"@tanstack/react-query": "^5.12.0",
|
||||
"axios": "^1.6.2",
|
||||
"chart.js": "^4.5.0",
|
||||
@@ -5805,6 +5807,29 @@
|
||||
"url": "https://opencollective.com/storybook"
|
||||
}
|
||||
},
|
||||
"node_modules/@stripe/react-stripe-js": {
|
||||
"version": "2.9.0",
|
||||
"resolved": "https://registry.npmjs.org/@stripe/react-stripe-js/-/react-stripe-js-2.9.0.tgz",
|
||||
"integrity": "sha512-+/j2g6qKAKuWSurhgRMfdlIdKM+nVVJCy/wl0US2Ccodlqx0WqfIIBhUkeONkCG+V/b+bZzcj4QVa3E/rXtT4Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"prop-types": "^15.7.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@stripe/stripe-js": "^1.44.1 || ^2.0.0 || ^3.0.0 || ^4.0.0",
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0",
|
||||
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@stripe/stripe-js": {
|
||||
"version": "3.5.0",
|
||||
"resolved": "https://registry.npmjs.org/@stripe/stripe-js/-/stripe-js-3.5.0.tgz",
|
||||
"integrity": "sha512-pKS3wZnJoL1iTyGBXAvCwduNNeghJHY6QSRSNNvpYnrrQrLZ6Owsazjyynu0e0ObRgks0i7Rv+pe2M7/MBTZpQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12.16"
|
||||
}
|
||||
},
|
||||
"node_modules/@surma/rollup-plugin-off-main-thread": {
|
||||
"version": "2.2.3",
|
||||
"resolved": "https://registry.npmjs.org/@surma/rollup-plugin-off-main-thread/-/rollup-plugin-off-main-thread-2.2.3.tgz",
|
||||
|
||||
@@ -26,6 +26,8 @@
|
||||
"@radix-ui/react-switch": "^1.0.3",
|
||||
"@radix-ui/react-tabs": "^1.0.4",
|
||||
"@radix-ui/react-tooltip": "^1.0.7",
|
||||
"@stripe/react-stripe-js": "^2.7.3",
|
||||
"@stripe/stripe-js": "^3.0.10",
|
||||
"@tanstack/react-query": "^5.12.0",
|
||||
"axios": "^1.6.2",
|
||||
"chart.js": "^4.5.0",
|
||||
|
||||
@@ -85,7 +85,7 @@ export class SubscriptionService {
|
||||
}
|
||||
|
||||
async getAvailablePlans(): Promise<AvailablePlans> {
|
||||
return apiClient.get<AvailablePlans>('/subscriptions/plans');
|
||||
return apiClient.get<AvailablePlans>('/plans');
|
||||
}
|
||||
|
||||
async validatePlanUpgrade(tenantId: string, planKey: string): Promise<PlanUpgradeValidation> {
|
||||
@@ -133,7 +133,7 @@ export class SubscriptionService {
|
||||
}
|
||||
|
||||
try {
|
||||
const plans = await apiClient.get<AvailablePlans>('/subscriptions/plans');
|
||||
const plans = await apiClient.get<AvailablePlans>('/plans');
|
||||
cachedPlans = plans;
|
||||
lastFetchTime = now;
|
||||
return plans;
|
||||
|
||||
285
frontend/src/components/domain/auth/PaymentForm.tsx
Normal file
285
frontend/src/components/domain/auth/PaymentForm.tsx
Normal file
@@ -0,0 +1,285 @@
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Card, Input, Button } from '../../ui';
|
||||
import { CardElement, useStripe, useElements } from '@stripe/react-stripe-js';
|
||||
import { AlertCircle, CheckCircle, CreditCard, Lock } from 'lucide-react';
|
||||
|
||||
interface PaymentFormProps {
|
||||
onPaymentSuccess: () => void;
|
||||
onPaymentError: (error: string) => void;
|
||||
className?: string;
|
||||
bypassPayment?: boolean;
|
||||
onBypassToggle?: () => void;
|
||||
}
|
||||
|
||||
const PaymentForm: React.FC<PaymentFormProps> = ({
|
||||
onPaymentSuccess,
|
||||
onPaymentError,
|
||||
className = '',
|
||||
bypassPayment = false,
|
||||
onBypassToggle
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const stripe = useStripe();
|
||||
const elements = useElements();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [cardComplete, setCardComplete] = useState(false);
|
||||
const [billingDetails, setBillingDetails] = useState({
|
||||
name: '',
|
||||
email: '',
|
||||
address: {
|
||||
line1: '',
|
||||
city: '',
|
||||
state: '',
|
||||
postal_code: '',
|
||||
country: 'ES',
|
||||
},
|
||||
});
|
||||
|
||||
// For development mode - bypass payment option
|
||||
const handleBypassPayment = () => {
|
||||
if (onBypassToggle) {
|
||||
onBypassToggle();
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!stripe || !elements) {
|
||||
// Stripe.js has not loaded yet
|
||||
onPaymentError('Stripe.js no ha cargado correctamente');
|
||||
return;
|
||||
}
|
||||
|
||||
if (bypassPayment) {
|
||||
// In development mode, bypass payment processing
|
||||
onPaymentSuccess();
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
// Create payment method
|
||||
const { error, paymentMethod } = await stripe.createPaymentMethod({
|
||||
type: 'card',
|
||||
card: elements.getElement('card')!,
|
||||
billing_details: {
|
||||
name: billingDetails.name,
|
||||
email: billingDetails.email,
|
||||
address: billingDetails.address,
|
||||
},
|
||||
});
|
||||
|
||||
if (error) {
|
||||
setError(error.message || 'Error al procesar el pago');
|
||||
onPaymentError(error.message || 'Error al procesar el pago');
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// In a real application, you would send the paymentMethod.id to your server
|
||||
// to create a subscription. For now, we'll simulate success.
|
||||
console.log('Payment method created:', paymentMethod);
|
||||
|
||||
onPaymentSuccess();
|
||||
} catch (err) {
|
||||
const errorMessage = err instanceof Error ? err.message : 'Error desconocido al procesar el pago';
|
||||
setError(errorMessage);
|
||||
onPaymentError(errorMessage);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCardChange = (event: any) => {
|
||||
setError(event.error?.message || null);
|
||||
setCardComplete(event.complete);
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className={`p-6 ${className}`}>
|
||||
<div className="text-center mb-6">
|
||||
<h3 className="text-xl font-bold text-text-primary mb-2 flex items-center justify-center gap-2">
|
||||
<CreditCard className="w-5 h-5" />
|
||||
{t('auth:payment.payment_info', 'Información de Pago')}
|
||||
</h3>
|
||||
<p className="text-text-secondary text-sm">
|
||||
{t('auth:payment.secure_payment', 'Tu información de pago está protegida con encriptación de extremo a extremo')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Development mode toggle */}
|
||||
<div className="mb-6 p-3 bg-yellow-50 border border-yellow-200 rounded-lg flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Lock className="w-4 h-4 text-yellow-600" />
|
||||
<span className="text-sm text-yellow-800">
|
||||
{t('auth:payment.dev_mode', 'Modo Desarrollo')}
|
||||
</span>
|
||||
</div>
|
||||
<Button
|
||||
variant={bypassPayment ? "primary" : "outline"}
|
||||
size="sm"
|
||||
onClick={handleBypassPayment}
|
||||
>
|
||||
{bypassPayment
|
||||
? t('auth:payment.payment_bypassed', 'Pago Bypassed')
|
||||
: t('auth:payment.bypass_payment', 'Bypass Pago')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{!bypassPayment && (
|
||||
<form onSubmit={handleSubmit}>
|
||||
{/* Billing Details */}
|
||||
<div className="space-y-4 mb-6">
|
||||
<Input
|
||||
label={t('auth:payment.cardholder_name', 'Nombre del titular')}
|
||||
placeholder="Nombre completo"
|
||||
value={billingDetails.name}
|
||||
onChange={(e) => setBillingDetails({...billingDetails, name: e.target.value})}
|
||||
required
|
||||
disabled={loading}
|
||||
/>
|
||||
|
||||
<Input
|
||||
type="email"
|
||||
label={t('auth:payment.email', 'Correo electrónico')}
|
||||
placeholder="tu.email@ejemplo.com"
|
||||
value={billingDetails.email}
|
||||
onChange={(e) => setBillingDetails({...billingDetails, email: e.target.value})}
|
||||
required
|
||||
disabled={loading}
|
||||
/>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<Input
|
||||
label={t('auth:payment.address_line1', 'Dirección')}
|
||||
placeholder="Calle y número"
|
||||
value={billingDetails.address.line1}
|
||||
onChange={(e) => setBillingDetails({...billingDetails, address: {...billingDetails.address, line1: e.target.value}})}
|
||||
required
|
||||
disabled={loading}
|
||||
/>
|
||||
|
||||
<Input
|
||||
label={t('auth:payment.city', 'Ciudad')}
|
||||
placeholder="Ciudad"
|
||||
value={billingDetails.address.city}
|
||||
onChange={(e) => setBillingDetails({...billingDetails, address: {...billingDetails.address, city: e.target.value}})}
|
||||
required
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<Input
|
||||
label={t('auth:payment.state', 'Estado/Provincia')}
|
||||
placeholder="Estado"
|
||||
value={billingDetails.address.state}
|
||||
onChange={(e) => setBillingDetails({...billingDetails, address: {...billingDetails.address, state: e.target.value}})}
|
||||
required
|
||||
disabled={loading}
|
||||
/>
|
||||
|
||||
<Input
|
||||
label={t('auth:payment.postal_code', 'Código Postal')}
|
||||
placeholder="Código postal"
|
||||
value={billingDetails.address.postal_code}
|
||||
onChange={(e) => setBillingDetails({...billingDetails, address: {...billingDetails.address, postal_code: e.target.value}})}
|
||||
required
|
||||
disabled={loading}
|
||||
/>
|
||||
|
||||
<Input
|
||||
label={t('auth:payment.country', 'País')}
|
||||
placeholder="País"
|
||||
value={billingDetails.address.country}
|
||||
onChange={(e) => setBillingDetails({...billingDetails, address: {...billingDetails.address, country: e.target.value}})}
|
||||
required
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Card Element */}
|
||||
<div className="mb-6">
|
||||
<label className="block text-sm font-medium text-text-primary mb-2">
|
||||
{t('auth:payment.card_details', 'Detalles de la tarjeta')}
|
||||
</label>
|
||||
<div className="border border-border-primary rounded-lg p-3 bg-bg-primary">
|
||||
<CardElement
|
||||
options={{
|
||||
style: {
|
||||
base: {
|
||||
fontSize: '16px',
|
||||
color: '#424770',
|
||||
'::placeholder': {
|
||||
color: '#aab7c4',
|
||||
},
|
||||
},
|
||||
invalid: {
|
||||
color: '#9e2146',
|
||||
},
|
||||
},
|
||||
}}
|
||||
onChange={handleCardChange}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-text-tertiary mt-2 flex items-center gap-1">
|
||||
<Lock className="w-3 h-3" />
|
||||
{t('auth:payment.card_info_secure', 'Tu información de tarjeta está segura')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Error Message */}
|
||||
{error && (
|
||||
<div className="mb-4 bg-color-error/10 border border-color-error/20 text-color-error px-4 py-3 rounded-lg text-sm flex items-start space-x-3" role="alert">
|
||||
<AlertCircle className="w-5 h-5 mt-0.5 flex-shrink-0" />
|
||||
<span>{error}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Submit Button */}
|
||||
<Button
|
||||
type="submit"
|
||||
variant="primary"
|
||||
size="lg"
|
||||
isLoading={loading}
|
||||
loadingText="Procesando pago..."
|
||||
disabled={!stripe || loading || (!cardComplete && !bypassPayment)}
|
||||
className="w-full"
|
||||
>
|
||||
{t('auth:payment.process_payment', 'Procesar Pago')}
|
||||
</Button>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{bypassPayment && (
|
||||
<div className="text-center py-8">
|
||||
<div className="inline-flex items-center justify-center w-16 h-16 rounded-full bg-green-100 mb-4">
|
||||
<CheckCircle className="w-8 h-8 text-green-600" />
|
||||
</div>
|
||||
<h4 className="text-lg font-semibold text-text-primary mb-2">
|
||||
{t('auth:payment.payment_bypassed_title', 'Pago Bypassed')}
|
||||
</h4>
|
||||
<p className="text-text-secondary mb-6">
|
||||
{t('auth:payment.payment_bypassed_description', 'El proceso de pago ha sido omitido en modo desarrollo. El registro continuará normalmente.')}
|
||||
</p>
|
||||
<Button
|
||||
variant="primary"
|
||||
size="lg"
|
||||
onClick={onPaymentSuccess}
|
||||
className="w-full"
|
||||
>
|
||||
{t('auth:payment.continue_registration', 'Continuar con el Registro')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default PaymentForm;
|
||||
@@ -4,6 +4,13 @@ import { Button, Input, Card } from '../../ui';
|
||||
import { PasswordCriteria, validatePassword, getPasswordErrors } from '../../ui/PasswordCriteria';
|
||||
import { useAuthActions, useAuthLoading, useAuthError } from '../../../stores/auth.store';
|
||||
import { useToast } from '../../../hooks/ui/useToast';
|
||||
import { SubscriptionSelection } from './SubscriptionSelection';
|
||||
import PaymentForm from './PaymentForm';
|
||||
import { loadStripe } from '@stripe/stripe-js';
|
||||
import { Elements } from '@stripe/react-stripe-js';
|
||||
|
||||
// Initialize Stripe - In production, use environment variable
|
||||
const stripePromise = loadStripe(import.meta.env.VITE_STRIPE_PUBLISHABLE_KEY || 'pk_test_51234567890123456789012345678901234567890123456789012345678901234567890123456789012345');
|
||||
|
||||
interface RegisterFormProps {
|
||||
onSuccess?: () => void;
|
||||
@@ -19,6 +26,9 @@ interface SimpleUserRegistration {
|
||||
acceptTerms: boolean;
|
||||
}
|
||||
|
||||
// Define the steps for the registration process
|
||||
type RegistrationStep = 'basic_info' | 'subscription' | 'payment';
|
||||
|
||||
export const RegisterForm: React.FC<RegisterFormProps> = ({
|
||||
onSuccess,
|
||||
onLoginClick,
|
||||
@@ -41,9 +51,15 @@ export const RegisterForm: React.FC<RegisterFormProps> = ({
|
||||
const isLoading = useAuthLoading();
|
||||
const error = useAuthError();
|
||||
const { success: showSuccessToast, error: showErrorToast } = useToast();
|
||||
|
||||
|
||||
// Multi-step form state
|
||||
const [currentStep, setCurrentStep] = useState<RegistrationStep>('basic_info');
|
||||
const [selectedPlan, setSelectedPlan] = useState<string>('starter');
|
||||
const [useTrial, setUseTrial] = useState<boolean>(false);
|
||||
const [bypassPayment, setBypassPayment] = useState<boolean>(false);
|
||||
|
||||
// Helper function to determine password match status
|
||||
const getPasswordMatchStatus = () => {
|
||||
const getPasswordMatchStatus = () => {
|
||||
if (!formData.confirmPassword) return 'empty';
|
||||
if (formData.password === formData.confirmPassword) return 'match';
|
||||
return 'mismatch';
|
||||
@@ -89,19 +105,35 @@ export const RegisterForm: React.FC<RegisterFormProps> = ({
|
||||
return Object.keys(newErrors).length === 0;
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!validateForm()) {
|
||||
return;
|
||||
const handleNextStep = () => {
|
||||
if (currentStep === 'basic_info') {
|
||||
if (!validateForm()) {
|
||||
return;
|
||||
}
|
||||
setCurrentStep('subscription');
|
||||
} else if (currentStep === 'subscription') {
|
||||
setCurrentStep('payment');
|
||||
}
|
||||
|
||||
};
|
||||
|
||||
const handlePreviousStep = () => {
|
||||
if (currentStep === 'subscription') {
|
||||
setCurrentStep('basic_info');
|
||||
} else if (currentStep === 'payment') {
|
||||
setCurrentStep('subscription');
|
||||
}
|
||||
};
|
||||
|
||||
const handleRegistrationSubmit = async (paymentMethodId?: string) => {
|
||||
try {
|
||||
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
|
||||
subscription_plan: selectedPlan,
|
||||
use_trial: useTrial,
|
||||
payment_method_id: paymentMethodId,
|
||||
};
|
||||
|
||||
await register(registrationData);
|
||||
@@ -115,239 +147,373 @@ export const RegisterForm: React.FC<RegisterFormProps> = ({
|
||||
title: t('auth:alerts.error_create', 'Error al crear la cuenta')
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handlePaymentSuccess = () => {
|
||||
handleRegistrationSubmit(); // In a real app, you would pass the payment method ID
|
||||
};
|
||||
|
||||
const handleInputChange = (field: keyof SimpleUserRegistration) => (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const handlePaymentError = (errorMessage: string) => {
|
||||
showErrorToast(errorMessage, {
|
||||
title: 'Error en el pago'
|
||||
});
|
||||
};
|
||||
|
||||
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 }));
|
||||
if (errors[field]) {
|
||||
setErrors(prev => ({ ...prev, [field]: undefined }));
|
||||
}
|
||||
};
|
||||
|
||||
// Render step indicator
|
||||
const renderStepIndicator = () => (
|
||||
<div className="flex justify-center mb-6">
|
||||
<div className="flex items-center space-x-4">
|
||||
<div className={`flex items-center justify-center w-8 h-8 rounded-full ${
|
||||
currentStep === 'basic_info' ? 'bg-color-primary text-white' :
|
||||
currentStep === 'subscription' || currentStep === 'payment' ? 'bg-color-success text-white' : 'bg-bg-secondary text-text-secondary'
|
||||
}`}>
|
||||
1
|
||||
</div>
|
||||
<div className="h-1 w-16 bg-border-primary"></div>
|
||||
<div className={`flex items-center justify-center w-8 h-8 rounded-full ${
|
||||
currentStep === 'subscription' ? 'bg-color-primary text-white' :
|
||||
currentStep === 'payment' ? 'bg-color-success text-white' : 'bg-bg-secondary text-text-secondary'
|
||||
}`}>
|
||||
2
|
||||
</div>
|
||||
<div className="h-1 w-16 bg-border-primary"></div>
|
||||
<div className={`flex items-center justify-center w-8 h-8 rounded-full ${
|
||||
currentStep === 'payment' ? 'bg-color-primary text-white' : 'bg-bg-secondary text-text-secondary'
|
||||
}`}>
|
||||
3
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
// Render current step
|
||||
const renderCurrentStep = () => {
|
||||
switch (currentStep) {
|
||||
case 'basic_info':
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="text-center mb-8">
|
||||
<h1 className="text-3xl font-bold text-text-primary mb-2">
|
||||
{t('auth:register.title', 'Crear Cuenta')}
|
||||
</h1>
|
||||
<p className="text-text-secondary text-lg">
|
||||
{t('auth:register.subtitle', 'Únete y comienza hoy mismo')}
|
||||
</p>
|
||||
<p className="text-sm text-text-tertiary mt-2">
|
||||
Paso 1 de 3: Información Básica
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={(e) => { e.preventDefault(); handleNextStep(); }} className="space-y-6">
|
||||
<Input
|
||||
label={t('auth:register.first_name', 'Nombre Completo')}
|
||||
placeholder="Juan Pérez García"
|
||||
value={formData.full_name}
|
||||
onChange={handleInputChange('full_name')}
|
||||
error={errors.full_name}
|
||||
disabled={isLoading}
|
||||
required
|
||||
autoComplete="name"
|
||||
leftIcon={
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
|
||||
</svg>
|
||||
}
|
||||
/>
|
||||
|
||||
<Input
|
||||
type="email"
|
||||
label={t('auth:register.email', 'Correo Electrónico')}
|
||||
placeholder="tu.email@ejemplo.com"
|
||||
value={formData.email}
|
||||
onChange={handleInputChange('email')}
|
||||
error={errors.email}
|
||||
disabled={isLoading}
|
||||
required
|
||||
autoComplete="email"
|
||||
leftIcon={
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M16 12a4 4 0 10-8 0 4 4 0 008 0zm0 0v1.5a2.5 0 005 0V12a9 9 0 10-9 9m4.5-1.206a8.959 8.959 0 01-4.5 1.207" />
|
||||
</svg>
|
||||
}
|
||||
/>
|
||||
|
||||
<Input
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
label="Contraseña"
|
||||
placeholder="Contraseña segura"
|
||||
value={formData.password}
|
||||
onChange={handleInputChange('password')}
|
||||
error={errors.password}
|
||||
disabled={isLoading}
|
||||
maxLength={128}
|
||||
autoComplete="new-password"
|
||||
required
|
||||
leftIcon={
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
|
||||
</svg>
|
||||
}
|
||||
rightIcon={
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className="text-text-secondary hover:text-text-primary"
|
||||
aria-label={showPassword ? 'Ocultar contraseña' : 'Mostrar contraseña'}
|
||||
>
|
||||
{showPassword ? (
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.878 9.878L3 3m6.878 6.878L21 21" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Password Criteria - Show when user is typing */}
|
||||
{formData.password && (
|
||||
<PasswordCriteria
|
||||
password={formData.password}
|
||||
className="mt-2"
|
||||
showOnlyFailed={false}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="relative">
|
||||
<Input
|
||||
type={showConfirmPassword ? 'text' : 'password'}
|
||||
label="Confirmar Contraseña"
|
||||
placeholder="Repite tu contraseña"
|
||||
value={formData.confirmPassword}
|
||||
onChange={handleInputChange('confirmPassword')}
|
||||
error={errors.confirmPassword}
|
||||
disabled={isLoading}
|
||||
maxLength={128}
|
||||
autoComplete="new-password"
|
||||
required
|
||||
className={
|
||||
passwordMatchStatus === 'match' && formData.confirmPassword
|
||||
? 'border-color-success focus:border-color-success ring-color-success'
|
||||
: passwordMatchStatus === 'mismatch' && formData.confirmPassword
|
||||
? 'border-color-error focus:border-color-error ring-color-error'
|
||||
: ''
|
||||
}
|
||||
leftIcon={
|
||||
passwordMatchStatus === 'match' && formData.confirmPassword ? (
|
||||
<svg className="w-5 h-5 text-color-success" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
) : passwordMatchStatus === 'mismatch' && formData.confirmPassword ? (
|
||||
<svg className="w-5 h-5 text-color-error" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg className="w-5 h-5 text-text-secondary" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 15v2m-6 0h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
rightIcon={
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowConfirmPassword(!showConfirmPassword)}
|
||||
className="text-text-secondary hover:text-text-primary"
|
||||
aria-label={showConfirmPassword ? 'Ocultar contraseña' : 'Mostrar contraseña'}
|
||||
>
|
||||
{showConfirmPassword ? (
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.878 9.878L3 3m6.878 6.878L21 21" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Password Match Status Message */}
|
||||
{formData.confirmPassword && (
|
||||
<div className="mt-2 transition-all duration-300 ease-in-out">
|
||||
{passwordMatchStatus === 'match' ? (
|
||||
<div className="flex items-center space-x-2 text-color-success animate-fade-in">
|
||||
<div className="flex-shrink-0 w-5 h-5 rounded-full bg-color-success/10 flex items-center justify-center">
|
||||
<svg className="w-3 h-3" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" />
|
||||
</svg>
|
||||
</div>
|
||||
<span className="text-sm font-medium">¡Las contraseñas coinciden!</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center space-x-2 text-color-error animate-fade-in">
|
||||
<div className="flex-shrink-0 w-5 h-5 rounded-full bg-color-error/10 flex items-center justify-center">
|
||||
<svg className="w-3 h-3" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clipRule="evenodd" />
|
||||
</svg>
|
||||
</div>
|
||||
<span className="text-sm font-medium">Las contraseñas no coinciden</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-4 pt-4 border-t border-border-primary">
|
||||
<div className="flex items-start space-x-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="acceptTerms"
|
||||
checked={formData.acceptTerms}
|
||||
onChange={handleInputChange('acceptTerms')}
|
||||
className="mt-1 h-4 w-4 rounded border-border-primary text-color-primary focus:ring-color-primary focus:ring-offset-0"
|
||||
disabled={isLoading}
|
||||
/>
|
||||
<label htmlFor="acceptTerms" className="text-sm text-text-secondary cursor-pointer">
|
||||
Acepto los{' '}
|
||||
<a href="#" className="text-color-primary hover:text-color-primary-dark underline">
|
||||
términos y condiciones
|
||||
</a>{' '}
|
||||
de uso
|
||||
</label>
|
||||
</div>
|
||||
{errors.acceptTerms && (
|
||||
<p className="text-color-error text-sm ml-7">{errors.acceptTerms}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between pt-4">
|
||||
<div></div> {/* Spacer for alignment */}
|
||||
<Button
|
||||
type="submit"
|
||||
variant="primary"
|
||||
size="lg"
|
||||
disabled={isLoading || !validatePassword(formData.password) || !formData.acceptTerms || passwordMatchStatus !== 'match'}
|
||||
className="w-48"
|
||||
>
|
||||
Siguiente
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
|
||||
case 'subscription':
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="text-center mb-8">
|
||||
<h1 className="text-3xl font-bold text-text-primary mb-2">
|
||||
{t('auth:subscription.select_plan', 'Selecciona tu plan')}
|
||||
</h1>
|
||||
<p className="text-text-secondary text-lg">
|
||||
{t('auth:subscription.choose_plan', 'Elige el plan que mejor se adapte a tu negocio')}
|
||||
</p>
|
||||
<p className="text-sm text-text-tertiary mt-2">
|
||||
Paso 2 de 3: Plan de Suscripción
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<SubscriptionSelection
|
||||
selectedPlan={selectedPlan}
|
||||
onPlanSelect={setSelectedPlan}
|
||||
showTrialOption={true}
|
||||
onTrialSelect={setUseTrial}
|
||||
trialSelected={useTrial}
|
||||
/>
|
||||
|
||||
<div className="flex justify-between pt-4">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="lg"
|
||||
onClick={handlePreviousStep}
|
||||
disabled={isLoading}
|
||||
className="w-48"
|
||||
>
|
||||
Anterior
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
size="lg"
|
||||
onClick={handleNextStep}
|
||||
disabled={isLoading}
|
||||
className="w-48"
|
||||
>
|
||||
Siguiente
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
case 'payment':
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="text-center mb-8">
|
||||
<h1 className="text-3xl font-bold text-text-primary mb-2">
|
||||
{t('auth:payment.payment_info', 'Información de Pago')}
|
||||
</h1>
|
||||
<p className="text-text-secondary text-lg">
|
||||
{t('auth:payment.secure_payment', 'Tu información de pago está protegida con encriptación de extremo a extremo')}
|
||||
</p>
|
||||
<p className="text-sm text-text-tertiary mt-2">
|
||||
Paso 3 de 3: Procesamiento de Pago
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Elements stripe={stripePromise}>
|
||||
<PaymentForm
|
||||
onPaymentSuccess={handlePaymentSuccess}
|
||||
onPaymentError={handlePaymentError}
|
||||
bypassPayment={bypassPayment}
|
||||
onBypassToggle={() => setBypassPayment(!bypassPayment)}
|
||||
/>
|
||||
</Elements>
|
||||
|
||||
<div className="flex justify-between pt-4">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="lg"
|
||||
onClick={handlePreviousStep}
|
||||
disabled={isLoading}
|
||||
className="w-48"
|
||||
>
|
||||
Anterior
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className={`p-8 w-full max-w-md ${className || ''}`} role="main">
|
||||
<div className="text-center mb-8">
|
||||
<h1 className="text-3xl font-bold text-text-primary mb-2">
|
||||
{t('auth:register.title', 'Crear Cuenta')}
|
||||
</h1>
|
||||
<p className="text-text-secondary text-lg">
|
||||
{t('auth:register.subtitle', 'Únete y comienza hoy mismo')}
|
||||
</p>
|
||||
</div>
|
||||
<Card className={`p-8 w-full max-w-3xl ${className || ''}`} role="main">
|
||||
{renderStepIndicator()}
|
||||
{renderCurrentStep()}
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
<Input
|
||||
label={t('auth:register.first_name', 'Nombre Completo')}
|
||||
placeholder="Juan Pérez García"
|
||||
value={formData.full_name}
|
||||
onChange={handleInputChange('full_name')}
|
||||
error={errors.full_name}
|
||||
disabled={isLoading}
|
||||
required
|
||||
autoComplete="name"
|
||||
leftIcon={
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
|
||||
</svg>
|
||||
}
|
||||
/>
|
||||
|
||||
<Input
|
||||
type="email"
|
||||
label={t('auth:register.email', 'Correo Electrónico')}
|
||||
placeholder="tu.email@ejemplo.com"
|
||||
value={formData.email}
|
||||
onChange={handleInputChange('email')}
|
||||
error={errors.email}
|
||||
disabled={isLoading}
|
||||
required
|
||||
autoComplete="email"
|
||||
leftIcon={
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M16 12a4 4 0 10-8 0 4 4 0 008 0zm0 0v1.5a2.5 2.5 0 005 0V12a9 9 0 10-9 9m4.5-1.206a8.959 8.959 0 01-4.5 1.207" />
|
||||
</svg>
|
||||
}
|
||||
/>
|
||||
|
||||
<Input
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
label="Contraseña"
|
||||
placeholder="Contraseña segura"
|
||||
value={formData.password}
|
||||
onChange={handleInputChange('password')}
|
||||
error={errors.password}
|
||||
disabled={isLoading}
|
||||
maxLength={128}
|
||||
autoComplete="new-password"
|
||||
required
|
||||
leftIcon={
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
|
||||
</svg>
|
||||
}
|
||||
rightIcon={
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className="text-text-secondary hover:text-text-primary"
|
||||
aria-label={showPassword ? 'Ocultar contraseña' : 'Mostrar contraseña'}
|
||||
>
|
||||
{showPassword ? (
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.878 9.878L3 3m6.878 6.878L21 21" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Password Criteria - Show when user is typing */}
|
||||
{formData.password && (
|
||||
<PasswordCriteria
|
||||
password={formData.password}
|
||||
className="mt-2"
|
||||
showOnlyFailed={false}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="relative">
|
||||
<Input
|
||||
type={showConfirmPassword ? 'text' : 'password'}
|
||||
label="Confirmar Contraseña"
|
||||
placeholder="Repite tu contraseña"
|
||||
value={formData.confirmPassword}
|
||||
onChange={handleInputChange('confirmPassword')}
|
||||
error={errors.confirmPassword}
|
||||
disabled={isLoading}
|
||||
maxLength={128}
|
||||
autoComplete="new-password"
|
||||
required
|
||||
className={
|
||||
passwordMatchStatus === 'match' && formData.confirmPassword
|
||||
? 'border-color-success focus:border-color-success ring-color-success'
|
||||
: passwordMatchStatus === 'mismatch' && formData.confirmPassword
|
||||
? 'border-color-error focus:border-color-error ring-color-error'
|
||||
: ''
|
||||
}
|
||||
leftIcon={
|
||||
passwordMatchStatus === 'match' && formData.confirmPassword ? (
|
||||
<svg className="w-5 h-5 text-color-success" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
) : passwordMatchStatus === 'mismatch' && formData.confirmPassword ? (
|
||||
<svg className="w-5 h-5 text-color-error" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg className="w-5 h-5 text-text-secondary" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 15v2m-6 0h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
rightIcon={
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowConfirmPassword(!showConfirmPassword)}
|
||||
className="text-text-secondary hover:text-text-primary"
|
||||
aria-label={showConfirmPassword ? 'Ocultar contraseña' : 'Mostrar contraseña'}
|
||||
>
|
||||
{showConfirmPassword ? (
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.878 9.878L3 3m6.878 6.878L21 21" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Password Match Status Message */}
|
||||
{formData.confirmPassword && (
|
||||
<div className="mt-2 transition-all duration-300 ease-in-out">
|
||||
{passwordMatchStatus === 'match' ? (
|
||||
<div className="flex items-center space-x-2 text-color-success animate-fade-in">
|
||||
<div className="flex-shrink-0 w-5 h-5 rounded-full bg-color-success/10 flex items-center justify-center">
|
||||
<svg className="w-3 h-3" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" />
|
||||
</svg>
|
||||
</div>
|
||||
<span className="text-sm font-medium">¡Las contraseñas coinciden!</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center space-x-2 text-color-error animate-fade-in">
|
||||
<div className="flex-shrink-0 w-5 h-5 rounded-full bg-color-error/10 flex items-center justify-center">
|
||||
<svg className="w-3 h-3" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clipRule="evenodd" />
|
||||
</svg>
|
||||
</div>
|
||||
<span className="text-sm font-medium">Las contraseñas no coinciden</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-4 pt-4 border-t border-border-primary">
|
||||
<div className="flex items-start space-x-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="acceptTerms"
|
||||
checked={formData.acceptTerms}
|
||||
onChange={handleInputChange('acceptTerms')}
|
||||
className="mt-1 h-4 w-4 rounded border-border-primary text-color-primary focus:ring-color-primary focus:ring-offset-0"
|
||||
disabled={isLoading}
|
||||
/>
|
||||
<label htmlFor="acceptTerms" className="text-sm text-text-secondary cursor-pointer">
|
||||
Acepto los{' '}
|
||||
<a href="#" className="text-color-primary hover:text-color-primary-dark underline">
|
||||
términos y condiciones
|
||||
</a>{' '}
|
||||
de uso
|
||||
</label>
|
||||
</div>
|
||||
{errors.acceptTerms && (
|
||||
<p className="text-color-error text-sm ml-7">{errors.acceptTerms}</p>
|
||||
)}
|
||||
{error && currentStep !== 'payment' && (
|
||||
<div className="bg-color-error/10 border border-color-error/20 text-color-error px-4 py-3 rounded-lg text-sm flex items-start space-x-3 mt-4" role="alert">
|
||||
<svg className="w-5 h-5 mt-0.5 flex-shrink-0" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7 4a1 1 0 11-2 0 1 1 0 012 0zm-1-9a1 1 0 00-1 1v4a1 1 0 102 0V6a1 1 0 00-1-1z" clipRule="evenodd" />
|
||||
</svg>
|
||||
<span>{error}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
variant="primary"
|
||||
size="lg"
|
||||
isLoading={isLoading}
|
||||
loadingText="Creando cuenta..."
|
||||
disabled={isLoading || !validatePassword(formData.password) || !formData.acceptTerms || passwordMatchStatus !== 'match'}
|
||||
className="w-full"
|
||||
onClick={(e) => {
|
||||
console.log('Button clicked!');
|
||||
// Let form submit handle it naturally
|
||||
}}
|
||||
>
|
||||
Crear Cuenta
|
||||
</Button>
|
||||
|
||||
{error && (
|
||||
<div className="bg-color-error/10 border border-color-error/20 text-color-error px-4 py-3 rounded-lg text-sm flex items-start space-x-3" role="alert">
|
||||
<svg className="w-5 h-5 mt-0.5 flex-shrink-0" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7 4a1 1 0 11-2 0 1 1 0 012 0zm-1-9a1 1 0 00-1 1v4a1 1 0 102 0V6a1 1 0 00-1-1z" clipRule="evenodd" />
|
||||
</svg>
|
||||
<span>{error}</span>
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
|
||||
{/* Login Link */}
|
||||
{onLoginClick && (
|
||||
{/* Login Link - only show on first step */}
|
||||
{onLoginClick && currentStep === 'basic_info' && (
|
||||
<div className="mt-8 text-center border-t border-border-primary pt-6">
|
||||
<p className="text-text-secondary mb-4">
|
||||
¿Ya tienes una cuenta?
|
||||
@@ -366,4 +532,4 @@ export const RegisterForm: React.FC<RegisterFormProps> = ({
|
||||
);
|
||||
};
|
||||
|
||||
export default RegisterForm;
|
||||
export default RegisterForm;
|
||||
|
||||
237
frontend/src/components/domain/auth/SubscriptionSelection.tsx
Normal file
237
frontend/src/components/domain/auth/SubscriptionSelection.tsx
Normal file
@@ -0,0 +1,237 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Card, Button, Badge } from '../../ui';
|
||||
import { CheckCircle, Users, MapPin, Package, TrendingUp, Star, ArrowRight } from 'lucide-react';
|
||||
import { subscriptionService, type AvailablePlans } from '../../../api';
|
||||
|
||||
interface SubscriptionSelectionProps {
|
||||
selectedPlan: string;
|
||||
onPlanSelect: (planKey: string) => void;
|
||||
showTrialOption?: boolean;
|
||||
onTrialSelect?: (useTrial: boolean) => void;
|
||||
trialSelected?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const SubscriptionSelection: React.FC<SubscriptionSelectionProps> = ({
|
||||
selectedPlan,
|
||||
onPlanSelect,
|
||||
showTrialOption = false,
|
||||
onTrialSelect,
|
||||
trialSelected = false,
|
||||
className = ''
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const [availablePlans, setAvailablePlans] = useState<AvailablePlans | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchPlans = async () => {
|
||||
try {
|
||||
const plans = await subscriptionService.getAvailablePlans();
|
||||
setAvailablePlans(plans);
|
||||
} catch (error) {
|
||||
console.error('Error fetching subscription plans:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchPlans();
|
||||
}, []);
|
||||
|
||||
if (loading || !availablePlans) {
|
||||
return (
|
||||
<div className="flex justify-center items-center py-8">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-color-primary"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const handleTrialToggle = () => {
|
||||
if (onTrialSelect) {
|
||||
onTrialSelect(!trialSelected);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`space-y-6 ${className}`}>
|
||||
<div className="text-center mb-8">
|
||||
<h2 className="text-2xl font-bold text-text-primary mb-2">
|
||||
{t('auth:subscription.select_plan', 'Selecciona tu plan')}
|
||||
</h2>
|
||||
<p className="text-text-secondary">
|
||||
{t('auth:subscription.choose_plan', 'Elige el plan que mejor se adapte a tu negocio')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{showTrialOption && (
|
||||
<Card className="p-4 mb-6 bg-blue-50 border-blue-200">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 bg-blue-100 rounded-lg">
|
||||
<Star className="w-5 h-5 text-blue-600" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold text-text-primary">
|
||||
{t('auth:subscription.trial_title', 'Prueba gratuita')}
|
||||
</h3>
|
||||
<p className="text-sm text-text-secondary">
|
||||
{t('auth:subscription.trial_description', 'Obtén 3 meses de prueba gratuita como usuario piloto')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant={trialSelected ? "primary" : "outline"}
|
||||
size="sm"
|
||||
onClick={handleTrialToggle}
|
||||
>
|
||||
{trialSelected
|
||||
? t('auth:subscription.trial_active', 'Activo')
|
||||
: t('auth:subscription.trial_activate', 'Activar')}
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
{Object.entries(availablePlans.plans).map(([planKey, plan]) => {
|
||||
const isSelected = selectedPlan === planKey;
|
||||
const getPlanColor = () => {
|
||||
switch (planKey) {
|
||||
case 'starter': return 'border-blue-500/30 bg-blue-500/5';
|
||||
case 'professional': return 'border-purple-500/30 bg-purple-500/5';
|
||||
case 'enterprise': return 'border-amber-500/30 bg-amber-500/5';
|
||||
default: return 'border-border-primary bg-bg-secondary';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card
|
||||
key={planKey}
|
||||
className={`relative p-6 cursor-pointer transition-all duration-200 hover:shadow-lg ${
|
||||
getPlanColor()
|
||||
} ${isSelected ? 'ring-2 ring-color-primary' : ''}`}
|
||||
onClick={() => onPlanSelect(planKey)}
|
||||
>
|
||||
{plan.popular && (
|
||||
<div className="absolute -top-3 left-1/2 transform -translate-x-1/2">
|
||||
<Badge variant="primary" className="px-3 py-1">
|
||||
<Star className="w-3 h-3 mr-1" />
|
||||
{t('auth:subscription.popular', 'Más Popular')}
|
||||
</Badge>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="text-center mb-6">
|
||||
<h4 className="text-xl font-bold text-text-primary mb-2">{plan.name}</h4>
|
||||
<div className="text-3xl font-bold text-color-primary mb-1">
|
||||
{subscriptionService.formatPrice(plan.monthly_price)}
|
||||
<span className="text-lg text-text-secondary">/mes</span>
|
||||
</div>
|
||||
<p className="text-sm text-text-secondary">{plan.description}</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3 mb-6">
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<Users className="w-4 h-4 text-color-primary" />
|
||||
<span>{plan.max_users === -1 ? 'Usuarios ilimitados' : `${plan.max_users} usuarios`}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<MapPin className="w-4 h-4 text-color-primary" />
|
||||
<span>{plan.max_locations === -1 ? 'Ubicaciones ilimitadas' : `${plan.max_locations} ubicación${plan.max_locations > 1 ? 'es' : ''}`}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<Package className="w-4 h-4 text-color-primary" />
|
||||
<span>{plan.max_products === -1 ? 'Productos ilimitados' : `${plan.max_products} productos`}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Features Section */}
|
||||
<div className="border-t border-border-color pt-4 mb-6">
|
||||
<h5 className="text-sm font-semibold text-text-primary mb-3 flex items-center">
|
||||
<TrendingUp className="w-4 h-4 mr-2 text-color-primary" />
|
||||
{t('auth:subscription.features', 'Funcionalidades Incluidas')}
|
||||
</h5>
|
||||
<div className="space-y-2">
|
||||
{(() => {
|
||||
const getPlanFeatures = (planKey: string) => {
|
||||
switch (planKey) {
|
||||
case 'starter':
|
||||
return [
|
||||
'✓ Panel de Control Básico',
|
||||
'✓ Gestión de Inventario',
|
||||
'✓ Gestión de Pedidos',
|
||||
'✓ Gestión de Proveedores',
|
||||
'✓ Punto de Venta Básico',
|
||||
'✗ Analytics Avanzados',
|
||||
'✗ Pronósticos IA',
|
||||
'✗ Insights Predictivos'
|
||||
];
|
||||
case 'professional':
|
||||
return [
|
||||
'✓ Panel de Control Avanzado',
|
||||
'✓ Gestión de Inventario Completa',
|
||||
'✓ Analytics de Ventas',
|
||||
'✓ Pronósticos con IA (92% precisión)',
|
||||
'✓ Análisis de Rendimiento',
|
||||
'✓ Optimización de Producción',
|
||||
'✓ Integración POS',
|
||||
'✗ Insights Predictivos Avanzados'
|
||||
];
|
||||
case 'enterprise':
|
||||
return [
|
||||
'✓ Todas las funcionalidades Professional',
|
||||
'✓ Insights Predictivos con IA',
|
||||
'✓ Analytics Multi-ubicación',
|
||||
'✓ Integración ERP',
|
||||
'✓ API Personalizada',
|
||||
'✓ Gestor de Cuenta Dedicado',
|
||||
'✓ Soporte 24/7 Prioritario',
|
||||
'✓ Demo Personalizada'
|
||||
];
|
||||
default:
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
return getPlanFeatures(planKey).map((feature, index) => (
|
||||
<div key={index} className={`text-xs flex items-center gap-2 ${
|
||||
feature.startsWith('✓')
|
||||
? 'text-green-600'
|
||||
: 'text-text-secondary opacity-60'
|
||||
}`}>
|
||||
<span>{feature}</span>
|
||||
</div>
|
||||
));
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant={isSelected ? "primary" : plan.popular ? "primary" : "outline"}
|
||||
className="w-full"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onPlanSelect(planKey);
|
||||
}}
|
||||
>
|
||||
{isSelected ? (
|
||||
<>
|
||||
<CheckCircle className="w-4 h-4 mr-2" />
|
||||
{t('auth:subscription.selected', 'Seleccionado')}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{t('auth:subscription.select', 'Seleccionar Plan')}
|
||||
<ArrowRight className="w-4 h-4 ml-2" />
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -107,13 +107,6 @@ export const StatusCard: React.FC<StatusCardProps> = ({
|
||||
const primaryActions = sortedActions.filter(action => action.priority === 'primary');
|
||||
const secondaryActions = sortedActions.filter(action => action.priority !== 'primary');
|
||||
|
||||
// Debug logging for actions
|
||||
if (actions.length > 0) {
|
||||
console.log('StatusCard - Title:', title, 'Actions received:', actions.length);
|
||||
console.log('StatusCard - Actions:', actions);
|
||||
console.log('StatusCard - Primary actions:', primaryActions.length, primaryActions);
|
||||
console.log('StatusCard - Secondary actions:', secondaryActions.length, secondaryActions);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card
|
||||
|
||||
@@ -162,5 +162,37 @@
|
||||
"manage_sales": "Gestionar ventas",
|
||||
"view_reports": "Ver reportes",
|
||||
"manage_settings": "Gestionar configuración"
|
||||
},
|
||||
"subscription": {
|
||||
"select_plan": "Selecciona tu plan",
|
||||
"choose_plan": "Elige el plan que mejor se adapte a tu negocio",
|
||||
"trial_title": "Prueba gratuita",
|
||||
"trial_description": "Obtén 3 meses de prueba gratuita como usuario piloto",
|
||||
"trial_activate": "Activar",
|
||||
"trial_active": "Activo",
|
||||
"features": "Funcionalidades Incluidas",
|
||||
"selected": "Seleccionado",
|
||||
"popular": "Más Popular",
|
||||
"select": "Seleccionar Plan"
|
||||
},
|
||||
"payment": {
|
||||
"payment_info": "Información de Pago",
|
||||
"secure_payment": "Tu información de pago está protegida con encriptación de extremo a extremo",
|
||||
"dev_mode": "Modo Desarrollo",
|
||||
"payment_bypassed": "Pago Bypassed",
|
||||
"bypass_payment": "Bypass Pago",
|
||||
"cardholder_name": "Nombre del titular",
|
||||
"email": "Correo electrónico",
|
||||
"address_line1": "Dirección",
|
||||
"city": "Ciudad",
|
||||
"state": "Estado/Provincia",
|
||||
"postal_code": "Código Postal",
|
||||
"country": "País",
|
||||
"card_details": "Detalles de la tarjeta",
|
||||
"card_info_secure": "Tu información de tarjeta está segura",
|
||||
"process_payment": "Procesar Pago",
|
||||
"payment_bypassed_title": "Pago Bypassed",
|
||||
"payment_bypassed_description": "El proceso de pago ha sido omitido en modo desarrollo. El registro continuará normalmente.",
|
||||
"continue_registration": "Continuar con el Registro"
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Crown, Users, MapPin, Package, TrendingUp, RefreshCw, AlertCircle, CheckCircle, ArrowRight, Star, ExternalLink, Download } from 'lucide-react';
|
||||
import { Crown, Users, MapPin, Package, TrendingUp, RefreshCw, AlertCircle, CheckCircle, ArrowRight, Star, ExternalLink, Download, CreditCard, X } from 'lucide-react';
|
||||
import { Button, Card, Badge, Modal } from '../../../../components/ui';
|
||||
import { PageHeader } from '../../../../components/layout';
|
||||
import { useAuthUser } from '../../../../stores/auth.store';
|
||||
@@ -18,6 +18,10 @@ const SubscriptionPage: React.FC = () => {
|
||||
const [upgradeDialogOpen, setUpgradeDialogOpen] = useState(false);
|
||||
const [selectedPlan, setSelectedPlan] = useState<string>('');
|
||||
const [upgrading, setUpgrading] = useState(false);
|
||||
const [cancellationDialogOpen, setCancellationDialogOpen] = useState(false);
|
||||
const [cancelling, setCancelling] = useState(false);
|
||||
const [invoices, setInvoices] = useState<any[]>([]);
|
||||
const [invoicesLoading, setInvoicesLoading] = useState(false);
|
||||
|
||||
// Load subscription data on component mount
|
||||
React.useEffect(() => {
|
||||
@@ -94,6 +98,70 @@ const SubscriptionPage: React.FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancellationClick = () => {
|
||||
setCancellationDialogOpen(true);
|
||||
};
|
||||
|
||||
const handleCancelSubscription = async () => {
|
||||
const tenantId = currentTenant?.id || user?.tenant_id;
|
||||
|
||||
if (!tenantId) {
|
||||
addToast('Información de tenant no disponible', { type: 'error' });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setCancelling(true);
|
||||
|
||||
// In a real implementation, this would call an API endpoint to cancel the subscription
|
||||
// const result = await subscriptionService.cancelSubscription(tenantId);
|
||||
|
||||
// For now, we'll simulate the cancellation
|
||||
addToast('Tu suscripción ha sido cancelada', { type: 'success' });
|
||||
|
||||
await loadSubscriptionData();
|
||||
setCancellationDialogOpen(false);
|
||||
} catch (error) {
|
||||
console.error('Error cancelling subscription:', error);
|
||||
addToast('Error al cancelar la suscripción', { type: 'error' });
|
||||
} finally {
|
||||
setCancelling(false);
|
||||
}
|
||||
};
|
||||
|
||||
const loadInvoices = async () => {
|
||||
const tenantId = currentTenant?.id || user?.tenant_id;
|
||||
|
||||
if (!tenantId) {
|
||||
addToast('No se encontró información del tenant', { type: 'error' });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setInvoicesLoading(true);
|
||||
// In a real implementation, this would call an API endpoint to get invoices
|
||||
// const invoices = await subscriptionService.getInvoices(tenantId);
|
||||
|
||||
// For now, we'll simulate some invoices
|
||||
setInvoices([
|
||||
{ id: 'inv_001', date: '2023-10-01', amount: 49.00, status: 'paid', description: 'Plan Starter Mensual' },
|
||||
{ id: 'inv_002', date: '2023-09-01', amount: 49.00, status: 'paid', description: 'Plan Starter Mensual' },
|
||||
{ id: 'inv_003', date: '2023-08-01', amount: 49.00, status: 'paid', description: 'Plan Starter Mensual' },
|
||||
]);
|
||||
} catch (error) {
|
||||
console.error('Error loading invoices:', error);
|
||||
addToast('Error al cargar las facturas', { type: 'error' });
|
||||
} finally {
|
||||
setInvoicesLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDownloadInvoice = (invoiceId: string) => {
|
||||
// In a real implementation, this would download the actual invoice
|
||||
console.log(`Downloading invoice: ${invoiceId}`);
|
||||
addToast(`Descargando factura ${invoiceId}`, { type: 'info' });
|
||||
};
|
||||
|
||||
const ProgressBar: React.FC<{ value: number; className?: string }> = ({ value, className = '' }) => {
|
||||
const getProgressColor = () => {
|
||||
if (value >= 90) return 'bg-red-500';
|
||||
@@ -148,7 +216,7 @@ const SubscriptionPage: React.FC = () => {
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h3 className="text-lg font-semibold text-[var(--text-primary)] flex items-center">
|
||||
<Crown className="w-5 h-5 mr-2 text-yellow-500" />
|
||||
Plan Actual: {subscriptionService.getPlanDisplayInfo(usageSummary.plan).name}
|
||||
Plan Actual: {usageSummary.plan}
|
||||
</h3>
|
||||
<Badge
|
||||
variant={usageSummary.status === 'active' ? 'success' : 'default'}
|
||||
@@ -418,6 +486,118 @@ const SubscriptionPage: React.FC = () => {
|
||||
})}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Invoices Section */}
|
||||
<Card className="p-6">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h3 className="text-lg font-semibold text-[var(--text-primary)] flex items-center">
|
||||
<Download className="w-5 h-5 mr-2 text-blue-500" />
|
||||
Historial de Facturas
|
||||
</h3>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={loadInvoices}
|
||||
disabled={invoicesLoading}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<RefreshCw className={`w-4 h-4 ${invoicesLoading ? 'animate-spin' : ''}`} />
|
||||
{invoicesLoading ? 'Cargando...' : 'Actualizar'}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{invoicesLoading ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<RefreshCw className="w-8 h-8 animate-spin text-[var(--color-primary)]" />
|
||||
<p className="text-[var(--text-secondary)]">Cargando facturas...</p>
|
||||
</div>
|
||||
</div>
|
||||
) : invoices.length === 0 ? (
|
||||
<div className="text-center py-8">
|
||||
<p className="text-[var(--text-secondary)]">No hay facturas disponibles</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="border-b border-[var(--border-color)]">
|
||||
<th className="text-left py-3 px-4 text-[var(--text-secondary)] font-medium">ID</th>
|
||||
<th className="text-left py-3 px-4 text-[var(--text-secondary)] font-medium">Fecha</th>
|
||||
<th className="text-left py-3 px-4 text-[var(--text-secondary)] font-medium">Descripción</th>
|
||||
<th className="text-left py-3 px-4 text-[var(--text-secondary)] font-medium">Monto</th>
|
||||
<th className="text-left py-3 px-4 text-[var(--text-secondary)] font-medium">Estado</th>
|
||||
<th className="text-left py-3 px-4 text-[var(--text-secondary)] font-medium">Acciones</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{invoices.map((invoice) => (
|
||||
<tr key={invoice.id} className="border-b border-[var(--border-color)] hover:bg-[var(--bg-secondary)]">
|
||||
<td className="py-3 px-4 text-[var(--text-primary)]">{invoice.id}</td>
|
||||
<td className="py-3 px-4 text-[var(--text-primary)]">{invoice.date}</td>
|
||||
<td className="py-3 px-4 text-[var(--text-primary)]">{invoice.description}</td>
|
||||
<td className="py-3 px-4 text-[var(--text-primary)]">{subscriptionService.formatPrice(invoice.amount)}</td>
|
||||
<td className="py-3 px-4">
|
||||
<Badge variant={invoice.status === 'paid' ? 'success' : 'default'}>
|
||||
{invoice.status === 'paid' ? 'Pagada' : 'Pendiente'}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="py-3 px-4">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleDownloadInvoice(invoice.id)}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<Download className="w-4 h-4" />
|
||||
Descargar
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* Subscription Management */}
|
||||
<Card className="p-6">
|
||||
<h3 className="text-lg font-semibold mb-6 text-[var(--text-primary)] flex items-center">
|
||||
<CreditCard className="w-5 h-5 mr-2 text-red-500" />
|
||||
Gestión de Suscripción
|
||||
</h3>
|
||||
|
||||
<div className="flex flex-col sm:flex-row gap-4">
|
||||
<div className="flex-1">
|
||||
<h4 className="font-medium text-[var(--text-primary)] mb-2">Cancelar Suscripción</h4>
|
||||
<p className="text-sm text-[var(--text-secondary)] mb-4">
|
||||
Si cancelas tu suscripción, perderás acceso a las funcionalidades premium al final del período de facturación actual.
|
||||
</p>
|
||||
<Button
|
||||
variant="danger"
|
||||
onClick={handleCancellationClick}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
Cancelar Suscripción
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1">
|
||||
<h4 className="font-medium text-[var(--text-primary)] mb-2">Método de Pago</h4>
|
||||
<p className="text-sm text-[var(--text-secondary)] mb-4">
|
||||
Actualiza tu información de pago para asegurar la continuidad de tu servicio.
|
||||
</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<CreditCard className="w-4 h-4" />
|
||||
Actualizar Método de Pago
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -436,7 +616,7 @@ const SubscriptionPage: React.FC = () => {
|
||||
<div className="p-4 bg-[var(--bg-secondary)] rounded-lg space-y-2">
|
||||
<div className="flex justify-between">
|
||||
<span>Plan actual:</span>
|
||||
<span>{subscriptionService.getPlanDisplayInfo(usageSummary.plan).name}</span>
|
||||
<span>{usageSummary.plan}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span>Nuevo plan:</span>
|
||||
@@ -469,8 +649,44 @@ const SubscriptionPage: React.FC = () => {
|
||||
</div>
|
||||
</Modal>
|
||||
)}
|
||||
|
||||
{/* Cancellation Modal */}
|
||||
{cancellationDialogOpen && (
|
||||
<Modal
|
||||
isOpen={cancellationDialogOpen}
|
||||
onClose={() => setCancellationDialogOpen(false)}
|
||||
title="Cancelar Suscripción"
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<p className="text-[var(--text-secondary)]">
|
||||
¿Estás seguro de que deseas cancelar tu suscripción? Esta acción no se puede deshacer.
|
||||
</p>
|
||||
<p className="text-[var(--text-secondary)]">
|
||||
Perderás acceso a las funcionalidades premium al final del período de facturación actual.
|
||||
</p>
|
||||
|
||||
<div className="flex gap-2 pt-4">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setCancellationDialogOpen(false)}
|
||||
className="flex-1"
|
||||
>
|
||||
Volver
|
||||
</Button>
|
||||
<Button
|
||||
variant="danger"
|
||||
onClick={handleCancelSubscription}
|
||||
disabled={cancelling}
|
||||
className="flex-1"
|
||||
>
|
||||
{cancelling ? 'Cancelando...' : 'Confirmar Cancelación'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SubscriptionPage;
|
||||
export default SubscriptionPage;
|
||||
|
||||
@@ -25,7 +25,7 @@ jwt_handler = JWTHandler(settings.JWT_SECRET_KEY, settings.JWT_ALGORITHM)
|
||||
# Routes that don't require authentication
|
||||
PUBLIC_ROUTES = [
|
||||
"/health",
|
||||
"/metrics",
|
||||
"/metrics",
|
||||
"/docs",
|
||||
"/redoc",
|
||||
"/openapi.json",
|
||||
@@ -33,7 +33,8 @@ PUBLIC_ROUTES = [
|
||||
"/api/v1/auth/register",
|
||||
"/api/v1/auth/refresh",
|
||||
"/api/v1/auth/verify",
|
||||
"/api/v1/nominatim/search"
|
||||
"/api/v1/nominatim/search",
|
||||
"/api/v1/plans"
|
||||
]
|
||||
|
||||
class AuthMiddleware(BaseHTTPMiddleware):
|
||||
|
||||
@@ -26,7 +26,13 @@ async def proxy_subscription_endpoints(request: Request, tenant_id: str = Path(.
|
||||
@router.api_route("/subscriptions/plans", methods=["GET", "OPTIONS"])
|
||||
async def proxy_subscription_plans(request: Request):
|
||||
"""Proxy subscription plans request to tenant service"""
|
||||
target_path = "/api/v1/plans/available"
|
||||
target_path = "/api/v1/plans"
|
||||
return await _proxy_to_tenant_service(request, target_path)
|
||||
|
||||
@router.api_route("/plans", methods=["GET", "OPTIONS"])
|
||||
async def proxy_plans(request: Request):
|
||||
"""Proxy plans request to tenant service"""
|
||||
target_path = "/api/v1/plans"
|
||||
return await _proxy_to_tenant_service(request, target_path)
|
||||
|
||||
# ================================================================
|
||||
|
||||
@@ -21,59 +21,67 @@ class ProductionAlertService(BaseAlertService, AlertServiceMixin):
|
||||
|
||||
def setup_scheduled_checks(self):
|
||||
"""Production-specific scheduled checks for alerts and recommendations"""
|
||||
|
||||
# Production capacity checks - every 10 minutes during business hours (alerts)
|
||||
|
||||
# Reduced frequency to prevent deadlocks and resource contention
|
||||
|
||||
# Production capacity checks - every 15 minutes during business hours (reduced from 10)
|
||||
self.scheduler.add_job(
|
||||
self.check_production_capacity,
|
||||
CronTrigger(minute='*/10', hour='6-20'),
|
||||
CronTrigger(minute='*/15', hour='6-20'),
|
||||
id='capacity_check',
|
||||
misfire_grace_time=60,
|
||||
max_instances=1
|
||||
misfire_grace_time=120, # Increased grace time
|
||||
max_instances=1,
|
||||
coalesce=True # Combine missed runs
|
||||
)
|
||||
|
||||
# Production delays - every 5 minutes during production hours (alerts)
|
||||
|
||||
# Production delays - every 10 minutes during production hours (reduced from 5)
|
||||
self.scheduler.add_job(
|
||||
self.check_production_delays,
|
||||
CronTrigger(minute='*/5', hour='4-22'),
|
||||
CronTrigger(minute='*/10', hour='4-22'),
|
||||
id='delay_check',
|
||||
misfire_grace_time=30,
|
||||
max_instances=1
|
||||
misfire_grace_time=60,
|
||||
max_instances=1,
|
||||
coalesce=True
|
||||
)
|
||||
|
||||
# Quality issues check - every 15 minutes (alerts)
|
||||
|
||||
# Quality issues check - every 20 minutes (reduced from 15)
|
||||
self.scheduler.add_job(
|
||||
self.check_quality_issues,
|
||||
CronTrigger(minute='*/15'),
|
||||
CronTrigger(minute='*/20'),
|
||||
id='quality_check',
|
||||
misfire_grace_time=60,
|
||||
max_instances=1
|
||||
misfire_grace_time=120,
|
||||
max_instances=1,
|
||||
coalesce=True
|
||||
)
|
||||
|
||||
# Equipment monitoring - check equipment status for maintenance alerts
|
||||
|
||||
# Equipment monitoring - check equipment status every 45 minutes (reduced from 30)
|
||||
self.scheduler.add_job(
|
||||
self.check_equipment_status,
|
||||
CronTrigger(minute='*/30'), # Check every 30 minutes
|
||||
CronTrigger(minute='*/45'),
|
||||
id='equipment_check',
|
||||
misfire_grace_time=30,
|
||||
max_instances=1
|
||||
misfire_grace_time=180,
|
||||
max_instances=1,
|
||||
coalesce=True
|
||||
)
|
||||
|
||||
# Efficiency recommendations - every 30 minutes (recommendations)
|
||||
|
||||
# Efficiency recommendations - every hour (reduced from 30 minutes)
|
||||
self.scheduler.add_job(
|
||||
self.generate_efficiency_recommendations,
|
||||
CronTrigger(minute='*/30'),
|
||||
CronTrigger(minute='0'),
|
||||
id='efficiency_recs',
|
||||
misfire_grace_time=120,
|
||||
max_instances=1
|
||||
misfire_grace_time=300,
|
||||
max_instances=1,
|
||||
coalesce=True
|
||||
)
|
||||
|
||||
# Energy optimization - every hour (recommendations)
|
||||
|
||||
# Energy optimization - every 2 hours (reduced from 1 hour)
|
||||
self.scheduler.add_job(
|
||||
self.generate_energy_recommendations,
|
||||
CronTrigger(minute='0'),
|
||||
CronTrigger(minute='0', hour='*/2'),
|
||||
id='energy_recs',
|
||||
misfire_grace_time=300,
|
||||
max_instances=1
|
||||
misfire_grace_time=600, # 10 minutes grace
|
||||
max_instances=1,
|
||||
coalesce=True
|
||||
)
|
||||
|
||||
logger.info("Production alert schedules configured",
|
||||
@@ -83,69 +91,47 @@ class ProductionAlertService(BaseAlertService, AlertServiceMixin):
|
||||
"""Check if production plan exceeds capacity (alerts)"""
|
||||
try:
|
||||
self._checks_performed += 1
|
||||
|
||||
query = """
|
||||
WITH capacity_analysis AS (
|
||||
SELECT
|
||||
p.tenant_id,
|
||||
p.planned_date,
|
||||
SUM(p.planned_quantity) as total_planned,
|
||||
MAX(pc.daily_capacity) as max_daily_capacity,
|
||||
COUNT(DISTINCT p.equipment_id) as equipment_count,
|
||||
AVG(pc.efficiency_percent) as avg_efficiency,
|
||||
CASE
|
||||
WHEN SUM(p.planned_quantity) > MAX(pc.daily_capacity) * 1.2 THEN 'severe_overload'
|
||||
WHEN SUM(p.planned_quantity) > MAX(pc.daily_capacity) THEN 'overload'
|
||||
WHEN SUM(p.planned_quantity) > MAX(pc.daily_capacity) * 0.9 THEN 'near_capacity'
|
||||
ELSE 'normal'
|
||||
END as capacity_status,
|
||||
(SUM(p.planned_quantity) / MAX(pc.daily_capacity)) * 100 as capacity_percentage
|
||||
FROM production_schedule p
|
||||
JOIN production_capacity pc ON pc.equipment_id = p.equipment_id
|
||||
WHERE p.planned_date >= CURRENT_DATE
|
||||
AND p.planned_date <= CURRENT_DATE + INTERVAL '3 days'
|
||||
AND p.status IN ('PENDING', 'IN_PROGRESS')
|
||||
AND p.tenant_id = $1
|
||||
GROUP BY p.tenant_id, p.planned_date
|
||||
)
|
||||
SELECT * FROM capacity_analysis
|
||||
WHERE capacity_status != 'normal'
|
||||
ORDER BY capacity_percentage DESC
|
||||
"""
|
||||
|
||||
# Check production capacity without tenant dependencies
|
||||
|
||||
# Use a simpler query with timeout and connection management
|
||||
from sqlalchemy import text
|
||||
simplified_query = text("""
|
||||
SELECT
|
||||
pb.tenant_id,
|
||||
DATE(pb.planned_start_time) as planned_date,
|
||||
COUNT(*) as batch_count,
|
||||
SUM(pb.planned_quantity) as total_planned,
|
||||
'capacity_check' as capacity_status,
|
||||
100.0 as capacity_percentage -- Default value for processing
|
||||
FROM production_batches pb
|
||||
WHERE pb.planned_start_time >= CURRENT_DATE
|
||||
AND pb.planned_start_time <= CURRENT_DATE + INTERVAL '3 days'
|
||||
AND pb.status IN ('PLANNED', 'PENDING', 'IN_PROGRESS')
|
||||
GROUP BY pb.tenant_id, DATE(pb.planned_start_time)
|
||||
HAVING COUNT(*) > 10 -- Alert if more than 10 batches per day
|
||||
ORDER BY total_planned DESC
|
||||
LIMIT 20 -- Limit results to prevent excessive processing
|
||||
""")
|
||||
|
||||
# Use timeout and proper session handling
|
||||
try:
|
||||
from sqlalchemy import text
|
||||
# Simplified query using only existing production tables
|
||||
simplified_query = text("""
|
||||
SELECT
|
||||
pb.tenant_id,
|
||||
DATE(pb.planned_start_time) as planned_date,
|
||||
COUNT(*) as batch_count,
|
||||
SUM(pb.planned_quantity) as total_planned,
|
||||
'capacity_check' as capacity_status
|
||||
FROM production_batches pb
|
||||
WHERE pb.planned_start_time >= CURRENT_DATE
|
||||
AND pb.planned_start_time <= CURRENT_DATE + INTERVAL '3 days'
|
||||
AND pb.status IN ('PLANNED', 'PENDING', 'IN_PROGRESS')
|
||||
GROUP BY pb.tenant_id, DATE(pb.planned_start_time)
|
||||
HAVING COUNT(*) > 10 -- Alert if more than 10 batches per day
|
||||
ORDER BY total_planned DESC
|
||||
""")
|
||||
|
||||
async with self.db_manager.get_session() as session:
|
||||
# Set statement timeout to prevent long-running queries
|
||||
await session.execute(text("SET statement_timeout = '30s'"))
|
||||
result = await session.execute(simplified_query)
|
||||
capacity_issues = result.fetchall()
|
||||
|
||||
|
||||
for issue in capacity_issues:
|
||||
await self._process_capacity_issue(issue.tenant_id, issue)
|
||||
|
||||
|
||||
except asyncio.TimeoutError:
|
||||
logger.warning("Capacity check timed out", service=self.config.SERVICE_NAME)
|
||||
self._errors_count += 1
|
||||
except Exception as e:
|
||||
logger.debug("Simplified capacity check failed", error=str(e))
|
||||
|
||||
logger.debug("Capacity check failed", error=str(e), service=self.config.SERVICE_NAME)
|
||||
|
||||
except Exception as e:
|
||||
# Skip capacity checks if tables don't exist (graceful degradation)
|
||||
if "does not exist" in str(e):
|
||||
if "does not exist" in str(e).lower() or "relation" in str(e).lower():
|
||||
logger.debug("Capacity check skipped - missing tables", error=str(e))
|
||||
else:
|
||||
logger.error("Capacity check failed", error=str(e))
|
||||
@@ -215,10 +201,10 @@ class ProductionAlertService(BaseAlertService, AlertServiceMixin):
|
||||
"""Check for production delays (alerts)"""
|
||||
try:
|
||||
self._checks_performed += 1
|
||||
|
||||
# Simplified query without customer_orders dependency
|
||||
query = """
|
||||
SELECT
|
||||
|
||||
# Simplified query with timeout and proper error handling
|
||||
query = text("""
|
||||
SELECT
|
||||
pb.id, pb.tenant_id, pb.product_name, pb.batch_number,
|
||||
pb.planned_end_time as planned_completion_time, pb.actual_start_time,
|
||||
pb.actual_end_time as estimated_completion_time, pb.status,
|
||||
@@ -232,24 +218,34 @@ class ProductionAlertService(BaseAlertService, AlertServiceMixin):
|
||||
OR pb.status IN ('ON_HOLD', 'QUALITY_CHECK')
|
||||
)
|
||||
AND pb.planned_end_time > NOW() - INTERVAL '24 hours'
|
||||
ORDER BY
|
||||
ORDER BY
|
||||
CASE COALESCE(pb.priority::text, 'MEDIUM')
|
||||
WHEN 'URGENT' THEN 1 WHEN 'HIGH' THEN 2 ELSE 3
|
||||
WHEN 'URGENT' THEN 1 WHEN 'HIGH' THEN 2 ELSE 3
|
||||
END,
|
||||
delay_minutes DESC
|
||||
"""
|
||||
|
||||
from sqlalchemy import text
|
||||
async with self.db_manager.get_session() as session:
|
||||
result = await session.execute(text(query))
|
||||
delays = result.fetchall()
|
||||
|
||||
for delay in delays:
|
||||
await self._process_production_delay(delay)
|
||||
|
||||
LIMIT 50 -- Limit results to prevent excessive processing
|
||||
""")
|
||||
|
||||
try:
|
||||
from sqlalchemy import text
|
||||
async with self.db_manager.get_session() as session:
|
||||
# Set statement timeout
|
||||
await session.execute(text("SET statement_timeout = '30s'"))
|
||||
result = await session.execute(query)
|
||||
delays = result.fetchall()
|
||||
|
||||
for delay in delays:
|
||||
await self._process_production_delay(delay)
|
||||
|
||||
except asyncio.TimeoutError:
|
||||
logger.warning("Production delay check timed out", service=self.config.SERVICE_NAME)
|
||||
self._errors_count += 1
|
||||
except Exception as e:
|
||||
logger.debug("Production delay check failed", error=str(e), service=self.config.SERVICE_NAME)
|
||||
|
||||
except Exception as e:
|
||||
# Skip delay checks if tables don't exist (graceful degradation)
|
||||
if "does not exist" in str(e):
|
||||
if "does not exist" in str(e).lower() or "relation" in str(e).lower():
|
||||
logger.debug("Production delay check skipped - missing tables", error=str(e))
|
||||
else:
|
||||
logger.error("Production delay check failed", error=str(e))
|
||||
|
||||
@@ -8,6 +8,7 @@ from typing import List, Dict, Any, Optional
|
||||
from uuid import UUID
|
||||
|
||||
from app.services.subscription_limit_service import SubscriptionLimitService
|
||||
from app.services.payment_service import PaymentService
|
||||
from app.repositories import SubscriptionRepository
|
||||
from app.models.tenants import Subscription
|
||||
from shared.auth.decorators import get_current_user_dep, require_admin_role_dep
|
||||
@@ -27,6 +28,13 @@ def get_subscription_limit_service():
|
||||
logger.error("Failed to create subscription limit service", error=str(e))
|
||||
raise HTTPException(status_code=500, detail="Service initialization failed")
|
||||
|
||||
def get_payment_service():
|
||||
try:
|
||||
return PaymentService()
|
||||
except Exception as e:
|
||||
logger.error("Failed to create payment service", error=str(e))
|
||||
raise HTTPException(status_code=500, detail="Payment service initialization failed")
|
||||
|
||||
def get_subscription_repository():
|
||||
try:
|
||||
from app.core.config import settings
|
||||
@@ -182,7 +190,7 @@ async def validate_plan_upgrade(
|
||||
"""Validate if tenant can upgrade to a new plan"""
|
||||
|
||||
try:
|
||||
# TODO: Add access control - verify user has admin access to tenant
|
||||
# TODO: Add access control - verify user is owner/admin of tenant
|
||||
result = await limit_service.validate_plan_upgrade(str(tenant_id), new_plan)
|
||||
return result
|
||||
|
||||
@@ -241,9 +249,9 @@ async def upgrade_subscription_plan(
|
||||
detail="Failed to upgrade subscription plan"
|
||||
)
|
||||
|
||||
@router.get("/plans/available")
|
||||
@router.get("/plans")
|
||||
async def get_available_plans():
|
||||
"""Get all available subscription plans with features and pricing"""
|
||||
"""Get all available subscription plans with features and pricing - Public endpoint"""
|
||||
|
||||
try:
|
||||
# This could be moved to a config service or database
|
||||
@@ -294,7 +302,7 @@ async def get_available_plans():
|
||||
"description": "Ideal para cadenas con obradores centrales",
|
||||
"monthly_price": 399.0,
|
||||
"max_users": -1, # Unlimited
|
||||
"max_locations": -1, # Unlimited
|
||||
"max_locations": -1, # Unlimited
|
||||
"max_products": -1, # Unlimited
|
||||
"features": {
|
||||
"inventory_management": "multi_location",
|
||||
@@ -321,4 +329,93 @@ async def get_available_plans():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Failed to get available plans"
|
||||
)
|
||||
)
|
||||
|
||||
# New endpoints for payment processing during registration
|
||||
@router.post("/subscriptions/register-with-subscription")
|
||||
async def register_with_subscription(
|
||||
user_data: Dict[str, Any],
|
||||
plan_id: str = Query(..., description="Plan ID to subscribe to"),
|
||||
payment_method_id: str = Query(..., description="Payment method ID from frontend"),
|
||||
use_trial: bool = Query(False, description="Whether to use trial period for pilot users"),
|
||||
payment_service: PaymentService = Depends(get_payment_service)
|
||||
):
|
||||
"""Process user registration with subscription creation"""
|
||||
|
||||
try:
|
||||
result = await payment_service.process_registration_with_subscription(
|
||||
user_data,
|
||||
plan_id,
|
||||
payment_method_id,
|
||||
use_trial
|
||||
)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"message": "Registration and subscription created successfully",
|
||||
"data": result
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error("Failed to register with subscription", error=str(e))
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Failed to register with subscription"
|
||||
)
|
||||
|
||||
@router.post("/subscriptions/{tenant_id}/cancel")
|
||||
async def cancel_subscription(
|
||||
tenant_id: UUID = Path(..., description="Tenant ID"),
|
||||
current_user: Dict[str, Any] = Depends(get_current_user_dep),
|
||||
payment_service: PaymentService = Depends(get_payment_service)
|
||||
):
|
||||
"""Cancel subscription for a tenant"""
|
||||
|
||||
try:
|
||||
# TODO: Add access control - verify user is owner/admin of tenant
|
||||
# In a real implementation, you would need to retrieve the subscription ID from the database
|
||||
# For now, this is a placeholder
|
||||
subscription_id = "sub_test" # This would come from the database
|
||||
|
||||
result = await payment_service.cancel_subscription(subscription_id)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"message": "Subscription cancelled successfully",
|
||||
"data": {
|
||||
"subscription_id": result.id,
|
||||
"status": result.status
|
||||
}
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error("Failed to cancel subscription", error=str(e))
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Failed to cancel subscription"
|
||||
)
|
||||
|
||||
@router.get("/subscriptions/{tenant_id}/invoices")
|
||||
async def get_invoices(
|
||||
tenant_id: UUID = Path(..., description="Tenant ID"),
|
||||
current_user: Dict[str, Any] = Depends(get_current_user_dep),
|
||||
payment_service: PaymentService = Depends(get_payment_service)
|
||||
):
|
||||
"""Get invoices for a tenant"""
|
||||
|
||||
try:
|
||||
# TODO: Add access control - verify user has access to tenant
|
||||
# In a real implementation, you would need to retrieve the customer ID from the database
|
||||
# For now, this is a placeholder
|
||||
customer_id = "cus_test" # This would come from the database
|
||||
|
||||
invoices = await payment_service.get_invoices(customer_id)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"data": invoices
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error("Failed to get invoices", error=str(e))
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Failed to get invoices"
|
||||
)
|
||||
|
||||
133
services/tenant/app/api/webhooks.py
Normal file
133
services/tenant/app/api/webhooks.py
Normal file
@@ -0,0 +1,133 @@
|
||||
"""
|
||||
Webhook endpoints for handling payment provider events
|
||||
These endpoints receive events from payment providers like Stripe
|
||||
"""
|
||||
|
||||
import structlog
|
||||
from fastapi import APIRouter, Depends, HTTPException, status, Request
|
||||
from typing import Dict, Any
|
||||
|
||||
from app.services.payment_service import PaymentService
|
||||
from shared.auth.decorators import get_current_user_dep
|
||||
from shared.monitoring.metrics import track_endpoint_metrics
|
||||
|
||||
logger = structlog.get_logger()
|
||||
router = APIRouter()
|
||||
|
||||
def get_payment_service():
|
||||
try:
|
||||
return PaymentService()
|
||||
except Exception as e:
|
||||
logger.error("Failed to create payment service", error=str(e))
|
||||
raise HTTPException(status_code=500, detail="Payment service initialization failed")
|
||||
|
||||
@router.post("/webhooks/stripe")
|
||||
async def stripe_webhook(
|
||||
request: Request,
|
||||
payment_service: PaymentService = Depends(get_payment_service)
|
||||
):
|
||||
"""
|
||||
Stripe webhook endpoint to handle payment events
|
||||
"""
|
||||
try:
|
||||
# Get the payload
|
||||
payload = await request.body()
|
||||
sig_header = request.headers.get('stripe-signature')
|
||||
|
||||
# In a real implementation, you would verify the signature
|
||||
# using the webhook signing secret
|
||||
# event = stripe.Webhook.construct_event(
|
||||
# payload, sig_header, settings.STRIPE_WEBHOOK_SECRET
|
||||
# )
|
||||
|
||||
# For now, we'll just log the event
|
||||
logger.info("Received Stripe webhook", payload=payload.decode('utf-8'))
|
||||
|
||||
# Process different types of events
|
||||
# event_type = event['type']
|
||||
# event_data = event['data']['object']
|
||||
|
||||
# Example processing for different event types:
|
||||
# if event_type == 'checkout.session.completed':
|
||||
# # Handle successful checkout
|
||||
# pass
|
||||
# elif event_type == 'customer.subscription.created':
|
||||
# # Handle new subscription
|
||||
# pass
|
||||
# elif event_type == 'customer.subscription.updated':
|
||||
# # Handle subscription update
|
||||
# pass
|
||||
# elif event_type == 'customer.subscription.deleted':
|
||||
# # Handle subscription cancellation
|
||||
# pass
|
||||
# elif event_type == 'invoice.payment_succeeded':
|
||||
# # Handle successful payment
|
||||
# pass
|
||||
# elif event_type == 'invoice.payment_failed':
|
||||
# # Handle failed payment
|
||||
# pass
|
||||
|
||||
return {"success": True}
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Error processing Stripe webhook", error=str(e))
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Webhook error"
|
||||
)
|
||||
|
||||
@router.post("/webhooks/generic")
|
||||
async def generic_webhook(
|
||||
request: Request,
|
||||
payment_service: PaymentService = Depends(get_payment_service)
|
||||
):
|
||||
"""
|
||||
Generic webhook endpoint that can handle events from any payment provider
|
||||
"""
|
||||
try:
|
||||
# Get the payload
|
||||
payload = await request.json()
|
||||
|
||||
# Log the event for debugging
|
||||
logger.info("Received generic webhook", payload=payload)
|
||||
|
||||
# Process the event based on its type
|
||||
event_type = payload.get('type', 'unknown')
|
||||
event_data = payload.get('data', {})
|
||||
|
||||
# Process different types of events
|
||||
if event_type == 'subscription.created':
|
||||
# Handle new subscription
|
||||
logger.info("Processing new subscription event", subscription_id=event_data.get('id'))
|
||||
# Update database with new subscription
|
||||
elif event_type == 'subscription.updated':
|
||||
# Handle subscription update
|
||||
logger.info("Processing subscription update event", subscription_id=event_data.get('id'))
|
||||
# Update database with subscription changes
|
||||
elif event_type == 'subscription.deleted':
|
||||
# Handle subscription cancellation
|
||||
logger.info("Processing subscription cancellation event", subscription_id=event_data.get('id'))
|
||||
# Update database with cancellation
|
||||
elif event_type == 'payment.succeeded':
|
||||
# Handle successful payment
|
||||
logger.info("Processing successful payment event", payment_id=event_data.get('id'))
|
||||
# Update payment status in database
|
||||
elif event_type == 'payment.failed':
|
||||
# Handle failed payment
|
||||
logger.info("Processing failed payment event", payment_id=event_data.get('id'))
|
||||
# Update payment status and notify user
|
||||
elif event_type == 'invoice.created':
|
||||
# Handle new invoice
|
||||
logger.info("Processing new invoice event", invoice_id=event_data.get('id'))
|
||||
# Store invoice information
|
||||
else:
|
||||
logger.warning("Unknown event type received", event_type=event_type)
|
||||
|
||||
return {"success": True}
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Error processing generic webhook", error=str(e))
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Webhook error"
|
||||
)
|
||||
@@ -66,5 +66,10 @@ class TenantSettings(BaseServiceSettings):
|
||||
GDPR_COMPLIANCE_ENABLED: bool = True
|
||||
DATA_EXPORT_ENABLED: bool = True
|
||||
DATA_DELETION_ENABLED: bool = True
|
||||
|
||||
# Stripe Payment Configuration
|
||||
STRIPE_PUBLISHABLE_KEY: str = os.getenv("STRIPE_PUBLISHABLE_KEY", "")
|
||||
STRIPE_SECRET_KEY: str = os.getenv("STRIPE_SECRET_KEY", "")
|
||||
STRIPE_WEBHOOK_SECRET: str = os.getenv("STRIPE_WEBHOOK_SECRET", "")
|
||||
|
||||
settings = TenantSettings()
|
||||
|
||||
@@ -9,7 +9,7 @@ from fastapi.middleware.cors import CORSMiddleware
|
||||
|
||||
from app.core.config import settings
|
||||
from app.core.database import database_manager
|
||||
from app.api import tenants, subscriptions
|
||||
from app.api import tenants, subscriptions, webhooks
|
||||
from shared.monitoring.logging import setup_logging
|
||||
from shared.monitoring.metrics import MetricsCollector
|
||||
|
||||
@@ -42,6 +42,7 @@ app.add_middleware(
|
||||
# Include routers
|
||||
app.include_router(tenants.router, prefix="/api/v1", tags=["tenants"])
|
||||
app.include_router(subscriptions.router, prefix="/api/v1", tags=["subscriptions"])
|
||||
app.include_router(webhooks.router, prefix="/api/v1", tags=["webhooks"])
|
||||
|
||||
@app.on_event("startup")
|
||||
async def startup_event():
|
||||
@@ -94,4 +95,4 @@ async def metrics():
|
||||
|
||||
if __name__ == "__main__":
|
||||
import uvicorn
|
||||
uvicorn.run(app, host="0.0.0.0", port=8000)
|
||||
uvicorn.run(app, host="0.0.0.0", port=8000)
|
||||
|
||||
152
services/tenant/app/services/payment_service.py
Normal file
152
services/tenant/app/services/payment_service.py
Normal file
@@ -0,0 +1,152 @@
|
||||
"""
|
||||
Payment Service for handling subscription payments
|
||||
This service abstracts payment provider interactions and makes the system payment-agnostic
|
||||
"""
|
||||
|
||||
import structlog
|
||||
from typing import Dict, Any, Optional
|
||||
import uuid
|
||||
|
||||
from app.core.config import settings
|
||||
from shared.clients.payment_client import PaymentProvider, PaymentCustomer, Subscription, PaymentMethod
|
||||
from shared.clients.stripe_client import StripeProvider
|
||||
from shared.database.base import create_database_manager
|
||||
from app.repositories.subscription_repository import SubscriptionRepository
|
||||
from app.models.tenants import Subscription as SubscriptionModel
|
||||
|
||||
logger = structlog.get_logger()
|
||||
|
||||
|
||||
class PaymentService:
|
||||
"""Service for handling payment provider interactions"""
|
||||
|
||||
def __init__(self):
|
||||
# Initialize payment provider based on configuration
|
||||
# For now, we'll use Stripe, but this can be swapped for other providers
|
||||
self.payment_provider: PaymentProvider = StripeProvider(
|
||||
api_key=settings.STRIPE_SECRET_KEY,
|
||||
webhook_secret=settings.STRIPE_WEBHOOK_SECRET
|
||||
)
|
||||
|
||||
# Initialize database components
|
||||
self.database_manager = create_database_manager(settings.DATABASE_URL, "tenant-service")
|
||||
self.subscription_repo = SubscriptionRepository(SubscriptionModel, None) # Will be set in methods
|
||||
|
||||
async def create_customer(self, user_data: Dict[str, Any]) -> PaymentCustomer:
|
||||
"""Create a customer in the payment provider system"""
|
||||
try:
|
||||
customer_data = {
|
||||
'email': user_data.get('email'),
|
||||
'name': user_data.get('full_name'),
|
||||
'metadata': {
|
||||
'user_id': user_data.get('user_id'),
|
||||
'tenant_id': user_data.get('tenant_id')
|
||||
}
|
||||
}
|
||||
|
||||
return await self.payment_provider.create_customer(customer_data)
|
||||
except Exception as e:
|
||||
logger.error("Failed to create customer in payment provider", error=str(e))
|
||||
raise e
|
||||
|
||||
async def create_subscription(
|
||||
self,
|
||||
customer_id: str,
|
||||
plan_id: str,
|
||||
payment_method_id: str,
|
||||
trial_period_days: Optional[int] = None
|
||||
) -> Subscription:
|
||||
"""Create a subscription for a customer"""
|
||||
try:
|
||||
return await self.payment_provider.create_subscription(
|
||||
customer_id,
|
||||
plan_id,
|
||||
payment_method_id,
|
||||
trial_period_days
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error("Failed to create subscription in payment provider", error=str(e))
|
||||
raise e
|
||||
|
||||
async def process_registration_with_subscription(
|
||||
self,
|
||||
user_data: Dict[str, Any],
|
||||
plan_id: str,
|
||||
payment_method_id: str,
|
||||
use_trial: bool = False
|
||||
) -> Dict[str, Any]:
|
||||
"""Process user registration with subscription creation"""
|
||||
try:
|
||||
# Create customer in payment provider
|
||||
customer = await self.create_customer(user_data)
|
||||
|
||||
# Determine trial period
|
||||
trial_period_days = None
|
||||
if use_trial:
|
||||
trial_period_days = 90 # 3 months trial for pilot users
|
||||
|
||||
# Create subscription
|
||||
subscription = await self.create_subscription(
|
||||
customer.id,
|
||||
plan_id,
|
||||
payment_method_id,
|
||||
trial_period_days
|
||||
)
|
||||
|
||||
# Save subscription to database
|
||||
async with self.database_manager.get_session() as session:
|
||||
self.subscription_repo.session = session
|
||||
subscription_record = await self.subscription_repo.create({
|
||||
'id': str(uuid.uuid4()),
|
||||
'tenant_id': user_data.get('tenant_id'),
|
||||
'customer_id': customer.id,
|
||||
'subscription_id': subscription.id,
|
||||
'plan_id': plan_id,
|
||||
'status': subscription.status,
|
||||
'current_period_start': subscription.current_period_start,
|
||||
'current_period_end': subscription.current_period_end,
|
||||
'created_at': subscription.created_at,
|
||||
'trial_period_days': trial_period_days
|
||||
})
|
||||
|
||||
return {
|
||||
'customer_id': customer.id,
|
||||
'subscription_id': subscription.id,
|
||||
'status': subscription.status,
|
||||
'trial_period_days': trial_period_days
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error("Failed to process registration with subscription", error=str(e))
|
||||
raise e
|
||||
|
||||
async def cancel_subscription(self, subscription_id: str) -> Subscription:
|
||||
"""Cancel a subscription in the payment provider"""
|
||||
try:
|
||||
return await self.payment_provider.cancel_subscription(subscription_id)
|
||||
except Exception as e:
|
||||
logger.error("Failed to cancel subscription in payment provider", error=str(e))
|
||||
raise e
|
||||
|
||||
async def update_payment_method(self, customer_id: str, payment_method_id: str) -> PaymentMethod:
|
||||
"""Update the payment method for a customer"""
|
||||
try:
|
||||
return await self.payment_provider.update_payment_method(customer_id, payment_method_id)
|
||||
except Exception as e:
|
||||
logger.error("Failed to update payment method in payment provider", error=str(e))
|
||||
raise e
|
||||
|
||||
async def get_invoices(self, customer_id: str) -> list:
|
||||
"""Get invoices for a customer from the payment provider"""
|
||||
try:
|
||||
return await self.payment_provider.get_invoices(customer_id)
|
||||
except Exception as e:
|
||||
logger.error("Failed to get invoices from payment provider", error=str(e))
|
||||
raise e
|
||||
|
||||
async def get_subscription(self, subscription_id: str) -> Subscription:
|
||||
"""Get subscription details from the payment provider"""
|
||||
try:
|
||||
return await self.payment_provider.get_subscription(subscription_id)
|
||||
except Exception as e:
|
||||
logger.error("Failed to get subscription from payment provider", error=str(e))
|
||||
raise e
|
||||
@@ -13,4 +13,5 @@ python-json-logger==2.0.4
|
||||
pytz==2023.3
|
||||
python-logstash==0.4.8
|
||||
structlog==23.2.0
|
||||
python-jose[cryptography]==3.3.0
|
||||
python-jose[cryptography]==3.3.0
|
||||
stripe==7.4.0
|
||||
|
||||
@@ -145,23 +145,58 @@ class BaseAlertService:
|
||||
|
||||
# PATTERN 3: Database Triggers
|
||||
async def start_database_listener(self):
|
||||
"""Listen for database notifications"""
|
||||
"""Listen for database notifications with connection management"""
|
||||
try:
|
||||
import asyncpg
|
||||
# Convert SQLAlchemy URL format to plain PostgreSQL for asyncpg
|
||||
database_url = self.config.DATABASE_URL
|
||||
if database_url.startswith('postgresql+asyncpg://'):
|
||||
database_url = database_url.replace('postgresql+asyncpg://', 'postgresql://')
|
||||
|
||||
conn = await asyncpg.connect(database_url)
|
||||
|
||||
# Register listeners based on service
|
||||
await self.register_db_listeners(conn)
|
||||
|
||||
logger.info("Database listeners registered", service=self.config.SERVICE_NAME)
|
||||
|
||||
|
||||
# Add connection timeout and retry logic
|
||||
max_retries = 3
|
||||
retry_count = 0
|
||||
conn = None
|
||||
|
||||
while retry_count < max_retries and not conn:
|
||||
try:
|
||||
conn = await asyncio.wait_for(
|
||||
asyncpg.connect(database_url),
|
||||
timeout=10.0
|
||||
)
|
||||
break
|
||||
except (asyncio.TimeoutError, Exception) as e:
|
||||
retry_count += 1
|
||||
if retry_count < max_retries:
|
||||
logger.warning(f"DB listener connection attempt {retry_count} failed, retrying...",
|
||||
service=self.config.SERVICE_NAME, error=str(e))
|
||||
await asyncio.sleep(2)
|
||||
else:
|
||||
raise
|
||||
|
||||
if conn:
|
||||
# Register listeners based on service
|
||||
await self.register_db_listeners(conn)
|
||||
logger.info("Database listeners registered", service=self.config.SERVICE_NAME)
|
||||
|
||||
# Keep connection alive with periodic ping
|
||||
asyncio.create_task(self._maintain_db_connection(conn))
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Failed to setup database listeners", service=self.config.SERVICE_NAME, error=str(e))
|
||||
|
||||
async def _maintain_db_connection(self, conn):
|
||||
"""Maintain database connection for listeners"""
|
||||
try:
|
||||
while not conn.is_closed():
|
||||
await asyncio.sleep(30) # Check every 30 seconds
|
||||
try:
|
||||
await conn.fetchval("SELECT 1")
|
||||
except Exception as e:
|
||||
logger.error("DB listener connection lost", service=self.config.SERVICE_NAME, error=str(e))
|
||||
break
|
||||
except Exception as e:
|
||||
logger.error("Error maintaining DB connection", service=self.config.SERVICE_NAME, error=str(e))
|
||||
|
||||
async def register_db_listeners(self, conn):
|
||||
"""Register database listeners - Override in service"""
|
||||
|
||||
121
shared/clients/payment_client.py
Normal file
121
shared/clients/payment_client.py
Normal file
@@ -0,0 +1,121 @@
|
||||
"""
|
||||
Payment Client Interface and Implementation
|
||||
This module provides an abstraction layer for payment providers to make the system payment-agnostic
|
||||
"""
|
||||
|
||||
import abc
|
||||
from typing import Dict, Any, Optional
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
@dataclass
|
||||
class PaymentCustomer:
|
||||
id: str
|
||||
email: str
|
||||
name: str
|
||||
created_at: datetime
|
||||
|
||||
|
||||
@dataclass
|
||||
class PaymentMethod:
|
||||
id: str
|
||||
type: str
|
||||
brand: Optional[str] = None
|
||||
last4: Optional[str] = None
|
||||
exp_month: Optional[int] = None
|
||||
exp_year: Optional[int] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class Subscription:
|
||||
id: str
|
||||
customer_id: str
|
||||
plan_id: str
|
||||
status: str # active, canceled, past_due, etc.
|
||||
current_period_start: datetime
|
||||
current_period_end: datetime
|
||||
created_at: datetime
|
||||
|
||||
|
||||
@dataclass
|
||||
class Invoice:
|
||||
id: str
|
||||
customer_id: str
|
||||
subscription_id: str
|
||||
amount: float
|
||||
currency: str
|
||||
status: str # draft, open, paid, void, etc.
|
||||
created_at: datetime
|
||||
due_date: Optional[datetime] = None
|
||||
description: Optional[str] = None
|
||||
|
||||
|
||||
class PaymentProvider(abc.ABC):
|
||||
"""
|
||||
Abstract base class for payment providers.
|
||||
All payment providers should implement this interface.
|
||||
"""
|
||||
|
||||
@abc.abstractmethod
|
||||
async def create_customer(self, customer_data: Dict[str, Any]) -> PaymentCustomer:
|
||||
"""
|
||||
Create a customer in the payment provider system
|
||||
"""
|
||||
pass
|
||||
|
||||
@abc.abstractmethod
|
||||
async def create_subscription(self, customer_id: str, plan_id: str, payment_method_id: str, trial_period_days: Optional[int] = None) -> Subscription:
|
||||
"""
|
||||
Create a subscription for a customer
|
||||
"""
|
||||
pass
|
||||
|
||||
@abc.abstractmethod
|
||||
async def update_payment_method(self, customer_id: str, payment_method_id: str) -> PaymentMethod:
|
||||
"""
|
||||
Update the payment method for a customer
|
||||
"""
|
||||
pass
|
||||
|
||||
@abc.abstractmethod
|
||||
async def cancel_subscription(self, subscription_id: str) -> Subscription:
|
||||
"""
|
||||
Cancel a subscription
|
||||
"""
|
||||
pass
|
||||
|
||||
@abc.abstractmethod
|
||||
async def get_invoices(self, customer_id: str) -> list[Invoice]:
|
||||
"""
|
||||
Get invoices for a customer
|
||||
"""
|
||||
pass
|
||||
|
||||
@abc.abstractmethod
|
||||
async def get_subscription(self, subscription_id: str) -> Subscription:
|
||||
"""
|
||||
Get subscription details
|
||||
"""
|
||||
pass
|
||||
|
||||
@abc.abstractmethod
|
||||
async def get_customer(self, customer_id: str) -> PaymentCustomer:
|
||||
"""
|
||||
Get customer details
|
||||
"""
|
||||
pass
|
||||
|
||||
@abc.abstractmethod
|
||||
async def create_setup_intent(self) -> Dict[str, Any]:
|
||||
"""
|
||||
Create a setup intent for saving payment methods
|
||||
"""
|
||||
pass
|
||||
|
||||
@abc.abstractmethod
|
||||
async def create_payment_intent(self, amount: float, currency: str, customer_id: str, payment_method_id: str) -> Dict[str, Any]:
|
||||
"""
|
||||
Create a payment intent for one-time payments
|
||||
"""
|
||||
pass
|
||||
246
shared/clients/stripe_client.py
Normal file
246
shared/clients/stripe_client.py
Normal file
@@ -0,0 +1,246 @@
|
||||
"""
|
||||
Stripe Payment Provider Implementation
|
||||
This module implements the PaymentProvider interface for Stripe
|
||||
"""
|
||||
|
||||
import stripe
|
||||
import structlog
|
||||
from typing import Dict, Any, Optional
|
||||
from datetime import datetime
|
||||
|
||||
from .payment_client import PaymentProvider, PaymentCustomer, PaymentMethod, Subscription, Invoice
|
||||
|
||||
|
||||
logger = structlog.get_logger()
|
||||
|
||||
|
||||
class StripeProvider(PaymentProvider):
|
||||
"""
|
||||
Stripe implementation of the PaymentProvider interface
|
||||
"""
|
||||
|
||||
def __init__(self, api_key: str, webhook_secret: Optional[str] = None):
|
||||
"""
|
||||
Initialize the Stripe provider with API key
|
||||
"""
|
||||
stripe.api_key = api_key
|
||||
self.webhook_secret = webhook_secret
|
||||
|
||||
async def create_customer(self, customer_data: Dict[str, Any]) -> PaymentCustomer:
|
||||
"""
|
||||
Create a customer in Stripe
|
||||
"""
|
||||
try:
|
||||
stripe_customer = stripe.Customer.create(
|
||||
email=customer_data.get('email'),
|
||||
name=customer_data.get('name'),
|
||||
phone=customer_data.get('phone'),
|
||||
metadata=customer_data.get('metadata', {})
|
||||
)
|
||||
|
||||
return PaymentCustomer(
|
||||
id=stripe_customer.id,
|
||||
email=stripe_customer.email,
|
||||
name=stripe_customer.name,
|
||||
created_at=datetime.fromtimestamp(stripe_customer.created)
|
||||
)
|
||||
except stripe.error.StripeError as e:
|
||||
logger.error("Failed to create Stripe customer", error=str(e))
|
||||
raise e
|
||||
|
||||
async def create_subscription(self, customer_id: str, plan_id: str, payment_method_id: str, trial_period_days: Optional[int] = None) -> Subscription:
|
||||
"""
|
||||
Create a subscription in Stripe
|
||||
"""
|
||||
try:
|
||||
# Attach payment method to customer
|
||||
stripe.PaymentMethod.attach(
|
||||
payment_method_id,
|
||||
customer=customer_id,
|
||||
)
|
||||
|
||||
# Set customer's default payment method
|
||||
stripe.Customer.modify(
|
||||
customer_id,
|
||||
invoice_settings={
|
||||
'default_payment_method': payment_method_id
|
||||
}
|
||||
)
|
||||
|
||||
# Create subscription with trial period if specified
|
||||
subscription_params = {
|
||||
'customer': customer_id,
|
||||
'items': [{'price': plan_id}],
|
||||
'default_payment_method': payment_method_id,
|
||||
}
|
||||
|
||||
if trial_period_days:
|
||||
subscription_params['trial_period_days'] = trial_period_days
|
||||
|
||||
stripe_subscription = stripe.Subscription.create(**subscription_params)
|
||||
|
||||
return Subscription(
|
||||
id=stripe_subscription.id,
|
||||
customer_id=stripe_subscription.customer,
|
||||
plan_id=plan_id, # Using the price ID as plan_id
|
||||
status=stripe_subscription.status,
|
||||
current_period_start=datetime.fromtimestamp(stripe_subscription.current_period_start),
|
||||
current_period_end=datetime.fromtimestamp(stripe_subscription.current_period_end),
|
||||
created_at=datetime.fromtimestamp(stripe_subscription.created)
|
||||
)
|
||||
except stripe.error.StripeError as e:
|
||||
logger.error("Failed to create Stripe subscription", error=str(e))
|
||||
raise e
|
||||
|
||||
async def update_payment_method(self, customer_id: str, payment_method_id: str) -> PaymentMethod:
|
||||
"""
|
||||
Update the payment method for a customer in Stripe
|
||||
"""
|
||||
try:
|
||||
# Attach payment method to customer
|
||||
stripe.PaymentMethod.attach(
|
||||
payment_method_id,
|
||||
customer=customer_id,
|
||||
)
|
||||
|
||||
# Set as default payment method
|
||||
stripe.Customer.modify(
|
||||
customer_id,
|
||||
invoice_settings={
|
||||
'default_payment_method': payment_method_id
|
||||
}
|
||||
)
|
||||
|
||||
stripe_payment_method = stripe.PaymentMethod.retrieve(payment_method_id)
|
||||
|
||||
return PaymentMethod(
|
||||
id=stripe_payment_method.id,
|
||||
type=stripe_payment_method.type,
|
||||
brand=getattr(stripe_payment_method, stripe_payment_method.type, {}).get('brand'),
|
||||
last4=getattr(stripe_payment_method, stripe_payment_method.type, {}).get('last4'),
|
||||
exp_month=getattr(stripe_payment_method, stripe_payment_method.type, {}).get('exp_month'),
|
||||
exp_year=getattr(stripe_payment_method, stripe_payment_method.type, {}).get('exp_year'),
|
||||
)
|
||||
except stripe.error.StripeError as e:
|
||||
logger.error("Failed to update Stripe payment method", error=str(e))
|
||||
raise e
|
||||
|
||||
async def cancel_subscription(self, subscription_id: str) -> Subscription:
|
||||
"""
|
||||
Cancel a subscription in Stripe
|
||||
"""
|
||||
try:
|
||||
stripe_subscription = stripe.Subscription.delete(subscription_id)
|
||||
|
||||
return Subscription(
|
||||
id=stripe_subscription.id,
|
||||
customer_id=stripe_subscription.customer,
|
||||
plan_id=subscription_id, # This would need to be retrieved differently in practice
|
||||
status=stripe_subscription.status,
|
||||
current_period_start=datetime.fromtimestamp(stripe_subscription.current_period_start),
|
||||
current_period_end=datetime.fromtimestamp(stripe_subscription.current_period_end),
|
||||
created_at=datetime.fromtimestamp(stripe_subscription.created)
|
||||
)
|
||||
except stripe.error.StripeError as e:
|
||||
logger.error("Failed to cancel Stripe subscription", error=str(e))
|
||||
raise e
|
||||
|
||||
async def get_invoices(self, customer_id: str) -> list[Invoice]:
|
||||
"""
|
||||
Get invoices for a customer from Stripe
|
||||
"""
|
||||
try:
|
||||
stripe_invoices = stripe.Invoice.list(customer=customer_id, limit=100)
|
||||
|
||||
invoices = []
|
||||
for stripe_invoice in stripe_invoices:
|
||||
invoices.append(Invoice(
|
||||
id=stripe_invoice.id,
|
||||
customer_id=stripe_invoice.customer,
|
||||
subscription_id=stripe_invoice.subscription,
|
||||
amount=stripe_invoice.amount_paid / 100.0, # Convert from cents
|
||||
currency=stripe_invoice.currency,
|
||||
status=stripe_invoice.status,
|
||||
created_at=datetime.fromtimestamp(stripe_invoice.created),
|
||||
due_date=datetime.fromtimestamp(stripe_invoice.due_date) if stripe_invoice.due_date else None,
|
||||
description=stripe_invoice.description
|
||||
))
|
||||
|
||||
return invoices
|
||||
except stripe.error.StripeError as e:
|
||||
logger.error("Failed to retrieve Stripe invoices", error=str(e))
|
||||
raise e
|
||||
|
||||
async def get_subscription(self, subscription_id: str) -> Subscription:
|
||||
"""
|
||||
Get subscription details from Stripe
|
||||
"""
|
||||
try:
|
||||
stripe_subscription = stripe.Subscription.retrieve(subscription_id)
|
||||
|
||||
return Subscription(
|
||||
id=stripe_subscription.id,
|
||||
customer_id=stripe_subscription.customer,
|
||||
plan_id=subscription_id, # This would need to be retrieved differently in practice
|
||||
status=stripe_subscription.status,
|
||||
current_period_start=datetime.fromtimestamp(stripe_subscription.current_period_start),
|
||||
current_period_end=datetime.fromtimestamp(stripe_subscription.current_period_end),
|
||||
created_at=datetime.fromtimestamp(stripe_subscription.created)
|
||||
)
|
||||
except stripe.error.StripeError as e:
|
||||
logger.error("Failed to retrieve Stripe subscription", error=str(e))
|
||||
raise e
|
||||
|
||||
async def get_customer(self, customer_id: str) -> PaymentCustomer:
|
||||
"""
|
||||
Get customer details from Stripe
|
||||
"""
|
||||
try:
|
||||
stripe_customer = stripe.Customer.retrieve(customer_id)
|
||||
|
||||
return PaymentCustomer(
|
||||
id=stripe_customer.id,
|
||||
email=stripe_customer.email,
|
||||
name=stripe_customer.name,
|
||||
created_at=datetime.fromtimestamp(stripe_customer.created)
|
||||
)
|
||||
except stripe.error.StripeError as e:
|
||||
logger.error("Failed to retrieve Stripe customer", error=str(e))
|
||||
raise e
|
||||
|
||||
async def create_setup_intent(self) -> Dict[str, Any]:
|
||||
"""
|
||||
Create a setup intent for saving payment methods in Stripe
|
||||
"""
|
||||
try:
|
||||
setup_intent = stripe.SetupIntent.create()
|
||||
|
||||
return {
|
||||
'client_secret': setup_intent.client_secret,
|
||||
'id': setup_intent.id
|
||||
}
|
||||
except stripe.error.StripeError as e:
|
||||
logger.error("Failed to create Stripe setup intent", error=str(e))
|
||||
raise e
|
||||
|
||||
async def create_payment_intent(self, amount: float, currency: str, customer_id: str, payment_method_id: str) -> Dict[str, Any]:
|
||||
"""
|
||||
Create a payment intent for one-time payments in Stripe
|
||||
"""
|
||||
try:
|
||||
payment_intent = stripe.PaymentIntent.create(
|
||||
amount=int(amount * 100), # Convert to cents
|
||||
currency=currency,
|
||||
customer=customer_id,
|
||||
payment_method=payment_method_id,
|
||||
confirm=True
|
||||
)
|
||||
|
||||
return {
|
||||
'id': payment_intent.id,
|
||||
'client_secret': payment_intent.client_secret,
|
||||
'status': payment_intent.status
|
||||
}
|
||||
except stripe.error.StripeError as e:
|
||||
logger.error("Failed to create Stripe payment intent", error=str(e))
|
||||
raise e
|
||||
@@ -187,6 +187,11 @@ class BaseServiceSettings(BaseSettings):
|
||||
WHATSAPP_BASE_URL: str = os.getenv("WHATSAPP_BASE_URL", "https://api.twilio.com")
|
||||
WHATSAPP_FROM_NUMBER: str = os.getenv("WHATSAPP_FROM_NUMBER", "")
|
||||
|
||||
# Stripe Payment Configuration
|
||||
STRIPE_PUBLISHABLE_KEY: str = os.getenv("STRIPE_PUBLISHABLE_KEY", "")
|
||||
STRIPE_SECRET_KEY: str = os.getenv("STRIPE_SECRET_KEY", "")
|
||||
STRIPE_WEBHOOK_SECRET: str = os.getenv("STRIPE_WEBHOOK_SECRET", "")
|
||||
|
||||
# ================================================================
|
||||
# ML & AI CONFIGURATION
|
||||
# ================================================================
|
||||
@@ -395,4 +400,4 @@ class BaseServiceSettings(BaseSettings):
|
||||
for setting in critical_settings:
|
||||
value = getattr(self, setting)
|
||||
if not value or 'change' in value.lower() or 'default' in value.lower():
|
||||
raise ValueError(f"{setting} must be properly configured for production")
|
||||
raise ValueError(f"{setting} must be properly configured for production")
|
||||
|
||||
Reference in New Issue
Block a user