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-switch": "^1.0.3",
|
||||||
"@radix-ui/react-tabs": "^1.0.4",
|
"@radix-ui/react-tabs": "^1.0.4",
|
||||||
"@radix-ui/react-tooltip": "^1.0.7",
|
"@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",
|
"@tanstack/react-query": "^5.12.0",
|
||||||
"axios": "^1.6.2",
|
"axios": "^1.6.2",
|
||||||
"chart.js": "^4.5.0",
|
"chart.js": "^4.5.0",
|
||||||
@@ -5805,6 +5807,29 @@
|
|||||||
"url": "https://opencollective.com/storybook"
|
"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": {
|
"node_modules/@surma/rollup-plugin-off-main-thread": {
|
||||||
"version": "2.2.3",
|
"version": "2.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/@surma/rollup-plugin-off-main-thread/-/rollup-plugin-off-main-thread-2.2.3.tgz",
|
"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-switch": "^1.0.3",
|
||||||
"@radix-ui/react-tabs": "^1.0.4",
|
"@radix-ui/react-tabs": "^1.0.4",
|
||||||
"@radix-ui/react-tooltip": "^1.0.7",
|
"@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",
|
"@tanstack/react-query": "^5.12.0",
|
||||||
"axios": "^1.6.2",
|
"axios": "^1.6.2",
|
||||||
"chart.js": "^4.5.0",
|
"chart.js": "^4.5.0",
|
||||||
|
|||||||
@@ -85,7 +85,7 @@ export class SubscriptionService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async getAvailablePlans(): Promise<AvailablePlans> {
|
async getAvailablePlans(): Promise<AvailablePlans> {
|
||||||
return apiClient.get<AvailablePlans>('/subscriptions/plans');
|
return apiClient.get<AvailablePlans>('/plans');
|
||||||
}
|
}
|
||||||
|
|
||||||
async validatePlanUpgrade(tenantId: string, planKey: string): Promise<PlanUpgradeValidation> {
|
async validatePlanUpgrade(tenantId: string, planKey: string): Promise<PlanUpgradeValidation> {
|
||||||
@@ -133,7 +133,7 @@ export class SubscriptionService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const plans = await apiClient.get<AvailablePlans>('/subscriptions/plans');
|
const plans = await apiClient.get<AvailablePlans>('/plans');
|
||||||
cachedPlans = plans;
|
cachedPlans = plans;
|
||||||
lastFetchTime = now;
|
lastFetchTime = now;
|
||||||
return plans;
|
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 { PasswordCriteria, validatePassword, getPasswordErrors } from '../../ui/PasswordCriteria';
|
||||||
import { useAuthActions, useAuthLoading, useAuthError } from '../../../stores/auth.store';
|
import { useAuthActions, useAuthLoading, useAuthError } from '../../../stores/auth.store';
|
||||||
import { useToast } from '../../../hooks/ui/useToast';
|
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 {
|
interface RegisterFormProps {
|
||||||
onSuccess?: () => void;
|
onSuccess?: () => void;
|
||||||
@@ -19,6 +26,9 @@ interface SimpleUserRegistration {
|
|||||||
acceptTerms: boolean;
|
acceptTerms: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Define the steps for the registration process
|
||||||
|
type RegistrationStep = 'basic_info' | 'subscription' | 'payment';
|
||||||
|
|
||||||
export const RegisterForm: React.FC<RegisterFormProps> = ({
|
export const RegisterForm: React.FC<RegisterFormProps> = ({
|
||||||
onSuccess,
|
onSuccess,
|
||||||
onLoginClick,
|
onLoginClick,
|
||||||
@@ -42,8 +52,14 @@ export const RegisterForm: React.FC<RegisterFormProps> = ({
|
|||||||
const error = useAuthError();
|
const error = useAuthError();
|
||||||
const { success: showSuccessToast, error: showErrorToast } = useToast();
|
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
|
// Helper function to determine password match status
|
||||||
const getPasswordMatchStatus = () => {
|
const getPasswordMatchStatus = () => {
|
||||||
if (!formData.confirmPassword) return 'empty';
|
if (!formData.confirmPassword) return 'empty';
|
||||||
if (formData.password === formData.confirmPassword) return 'match';
|
if (formData.password === formData.confirmPassword) return 'match';
|
||||||
return 'mismatch';
|
return 'mismatch';
|
||||||
@@ -89,19 +105,35 @@ export const RegisterForm: React.FC<RegisterFormProps> = ({
|
|||||||
return Object.keys(newErrors).length === 0;
|
return Object.keys(newErrors).length === 0;
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
const handleNextStep = () => {
|
||||||
e.preventDefault();
|
if (currentStep === 'basic_info') {
|
||||||
|
if (!validateForm()) {
|
||||||
if (!validateForm()) {
|
return;
|
||||||
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 {
|
try {
|
||||||
const registrationData = {
|
const registrationData = {
|
||||||
full_name: formData.full_name,
|
full_name: formData.full_name,
|
||||||
email: formData.email,
|
email: formData.email,
|
||||||
password: formData.password,
|
password: formData.password,
|
||||||
tenant_name: 'Default Bakery', // Default value since we're not collecting it
|
tenant_name: 'Default Bakery', // Default value since we're not collecting it
|
||||||
|
subscription_plan: selectedPlan,
|
||||||
|
use_trial: useTrial,
|
||||||
|
payment_method_id: paymentMethodId,
|
||||||
};
|
};
|
||||||
|
|
||||||
await register(registrationData);
|
await register(registrationData);
|
||||||
@@ -115,239 +147,373 @@ export const RegisterForm: React.FC<RegisterFormProps> = ({
|
|||||||
title: t('auth:alerts.error_create', 'Error al crear la cuenta')
|
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;
|
const value = e.target.type === 'checkbox' ? e.target.checked : e.target.value;
|
||||||
setFormData(prev => ({ ...prev, [field]: value }));
|
setFormData(prev => ({ ...prev, [field]: value }));
|
||||||
if (errors[field]) {
|
if (errors[field]) {
|
||||||
setErrors(prev => ({ ...prev, [field]: undefined }));
|
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 (
|
return (
|
||||||
<Card className={`p-8 w-full max-w-md ${className || ''}`} role="main">
|
<Card className={`p-8 w-full max-w-3xl ${className || ''}`} role="main">
|
||||||
<div className="text-center mb-8">
|
{renderStepIndicator()}
|
||||||
<h1 className="text-3xl font-bold text-text-primary mb-2">
|
{renderCurrentStep()}
|
||||||
{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>
|
|
||||||
|
|
||||||
<form onSubmit={handleSubmit} className="space-y-6">
|
{error && currentStep !== 'payment' && (
|
||||||
<Input
|
<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">
|
||||||
label={t('auth:register.first_name', 'Nombre Completo')}
|
<svg className="w-5 h-5 mt-0.5 flex-shrink-0" fill="currentColor" viewBox="0 0 20 20">
|
||||||
placeholder="Juan Pérez García"
|
<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" />
|
||||||
value={formData.full_name}
|
</svg>
|
||||||
onChange={handleInputChange('full_name')}
|
<span>{error}</span>
|
||||||
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>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="space-y-4 pt-4 border-t border-border-primary">
|
{/* Login Link - only show on first step */}
|
||||||
<div className="flex items-start space-x-3">
|
{onLoginClick && currentStep === 'basic_info' && (
|
||||||
<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>
|
|
||||||
|
|
||||||
<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 && (
|
|
||||||
<div className="mt-8 text-center border-t border-border-primary pt-6">
|
<div className="mt-8 text-center border-t border-border-primary pt-6">
|
||||||
<p className="text-text-secondary mb-4">
|
<p className="text-text-secondary mb-4">
|
||||||
¿Ya tienes una cuenta?
|
¿Ya tienes una cuenta?
|
||||||
|
|||||||
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 primaryActions = sortedActions.filter(action => action.priority === 'primary');
|
||||||
const secondaryActions = 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 (
|
return (
|
||||||
<Card
|
<Card
|
||||||
|
|||||||
@@ -162,5 +162,37 @@
|
|||||||
"manage_sales": "Gestionar ventas",
|
"manage_sales": "Gestionar ventas",
|
||||||
"view_reports": "Ver reportes",
|
"view_reports": "Ver reportes",
|
||||||
"manage_settings": "Gestionar configuración"
|
"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 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 { Button, Card, Badge, Modal } from '../../../../components/ui';
|
||||||
import { PageHeader } from '../../../../components/layout';
|
import { PageHeader } from '../../../../components/layout';
|
||||||
import { useAuthUser } from '../../../../stores/auth.store';
|
import { useAuthUser } from '../../../../stores/auth.store';
|
||||||
@@ -18,6 +18,10 @@ const SubscriptionPage: React.FC = () => {
|
|||||||
const [upgradeDialogOpen, setUpgradeDialogOpen] = useState(false);
|
const [upgradeDialogOpen, setUpgradeDialogOpen] = useState(false);
|
||||||
const [selectedPlan, setSelectedPlan] = useState<string>('');
|
const [selectedPlan, setSelectedPlan] = useState<string>('');
|
||||||
const [upgrading, setUpgrading] = useState(false);
|
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
|
// Load subscription data on component mount
|
||||||
React.useEffect(() => {
|
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 ProgressBar: React.FC<{ value: number; className?: string }> = ({ value, className = '' }) => {
|
||||||
const getProgressColor = () => {
|
const getProgressColor = () => {
|
||||||
if (value >= 90) return 'bg-red-500';
|
if (value >= 90) return 'bg-red-500';
|
||||||
@@ -148,7 +216,7 @@ const SubscriptionPage: React.FC = () => {
|
|||||||
<div className="flex items-center justify-between mb-6">
|
<div className="flex items-center justify-between mb-6">
|
||||||
<h3 className="text-lg font-semibold text-[var(--text-primary)] flex items-center">
|
<h3 className="text-lg font-semibold text-[var(--text-primary)] flex items-center">
|
||||||
<Crown className="w-5 h-5 mr-2 text-yellow-500" />
|
<Crown className="w-5 h-5 mr-2 text-yellow-500" />
|
||||||
Plan Actual: {subscriptionService.getPlanDisplayInfo(usageSummary.plan).name}
|
Plan Actual: {usageSummary.plan}
|
||||||
</h3>
|
</h3>
|
||||||
<Badge
|
<Badge
|
||||||
variant={usageSummary.status === 'active' ? 'success' : 'default'}
|
variant={usageSummary.status === 'active' ? 'success' : 'default'}
|
||||||
@@ -418,6 +486,118 @@ const SubscriptionPage: React.FC = () => {
|
|||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</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="p-4 bg-[var(--bg-secondary)] rounded-lg space-y-2">
|
||||||
<div className="flex justify-between">
|
<div className="flex justify-between">
|
||||||
<span>Plan actual:</span>
|
<span>Plan actual:</span>
|
||||||
<span>{subscriptionService.getPlanDisplayInfo(usageSummary.plan).name}</span>
|
<span>{usageSummary.plan}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between">
|
<div className="flex justify-between">
|
||||||
<span>Nuevo plan:</span>
|
<span>Nuevo plan:</span>
|
||||||
@@ -469,6 +649,42 @@ const SubscriptionPage: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
</Modal>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -33,7 +33,8 @@ PUBLIC_ROUTES = [
|
|||||||
"/api/v1/auth/register",
|
"/api/v1/auth/register",
|
||||||
"/api/v1/auth/refresh",
|
"/api/v1/auth/refresh",
|
||||||
"/api/v1/auth/verify",
|
"/api/v1/auth/verify",
|
||||||
"/api/v1/nominatim/search"
|
"/api/v1/nominatim/search",
|
||||||
|
"/api/v1/plans"
|
||||||
]
|
]
|
||||||
|
|
||||||
class AuthMiddleware(BaseHTTPMiddleware):
|
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"])
|
@router.api_route("/subscriptions/plans", methods=["GET", "OPTIONS"])
|
||||||
async def proxy_subscription_plans(request: Request):
|
async def proxy_subscription_plans(request: Request):
|
||||||
"""Proxy subscription plans request to tenant service"""
|
"""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)
|
return await _proxy_to_tenant_service(request, target_path)
|
||||||
|
|
||||||
# ================================================================
|
# ================================================================
|
||||||
|
|||||||
@@ -22,58 +22,66 @@ class ProductionAlertService(BaseAlertService, AlertServiceMixin):
|
|||||||
def setup_scheduled_checks(self):
|
def setup_scheduled_checks(self):
|
||||||
"""Production-specific scheduled checks for alerts and recommendations"""
|
"""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.scheduler.add_job(
|
||||||
self.check_production_capacity,
|
self.check_production_capacity,
|
||||||
CronTrigger(minute='*/10', hour='6-20'),
|
CronTrigger(minute='*/15', hour='6-20'),
|
||||||
id='capacity_check',
|
id='capacity_check',
|
||||||
misfire_grace_time=60,
|
misfire_grace_time=120, # Increased grace time
|
||||||
max_instances=1
|
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.scheduler.add_job(
|
||||||
self.check_production_delays,
|
self.check_production_delays,
|
||||||
CronTrigger(minute='*/5', hour='4-22'),
|
CronTrigger(minute='*/10', hour='4-22'),
|
||||||
id='delay_check',
|
id='delay_check',
|
||||||
misfire_grace_time=30,
|
misfire_grace_time=60,
|
||||||
max_instances=1
|
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.scheduler.add_job(
|
||||||
self.check_quality_issues,
|
self.check_quality_issues,
|
||||||
CronTrigger(minute='*/15'),
|
CronTrigger(minute='*/20'),
|
||||||
id='quality_check',
|
id='quality_check',
|
||||||
misfire_grace_time=60,
|
misfire_grace_time=120,
|
||||||
max_instances=1
|
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.scheduler.add_job(
|
||||||
self.check_equipment_status,
|
self.check_equipment_status,
|
||||||
CronTrigger(minute='*/30'), # Check every 30 minutes
|
CronTrigger(minute='*/45'),
|
||||||
id='equipment_check',
|
id='equipment_check',
|
||||||
misfire_grace_time=30,
|
misfire_grace_time=180,
|
||||||
max_instances=1
|
max_instances=1,
|
||||||
|
coalesce=True
|
||||||
)
|
)
|
||||||
|
|
||||||
# Efficiency recommendations - every 30 minutes (recommendations)
|
# Efficiency recommendations - every hour (reduced from 30 minutes)
|
||||||
self.scheduler.add_job(
|
self.scheduler.add_job(
|
||||||
self.generate_efficiency_recommendations,
|
self.generate_efficiency_recommendations,
|
||||||
CronTrigger(minute='*/30'),
|
CronTrigger(minute='0'),
|
||||||
id='efficiency_recs',
|
id='efficiency_recs',
|
||||||
misfire_grace_time=120,
|
misfire_grace_time=300,
|
||||||
max_instances=1
|
max_instances=1,
|
||||||
|
coalesce=True
|
||||||
)
|
)
|
||||||
|
|
||||||
# Energy optimization - every hour (recommendations)
|
# Energy optimization - every 2 hours (reduced from 1 hour)
|
||||||
self.scheduler.add_job(
|
self.scheduler.add_job(
|
||||||
self.generate_energy_recommendations,
|
self.generate_energy_recommendations,
|
||||||
CronTrigger(minute='0'),
|
CronTrigger(minute='0', hour='*/2'),
|
||||||
id='energy_recs',
|
id='energy_recs',
|
||||||
misfire_grace_time=300,
|
misfire_grace_time=600, # 10 minutes grace
|
||||||
max_instances=1
|
max_instances=1,
|
||||||
|
coalesce=True
|
||||||
)
|
)
|
||||||
|
|
||||||
logger.info("Production alert schedules configured",
|
logger.info("Production alert schedules configured",
|
||||||
@@ -84,68 +92,46 @@ class ProductionAlertService(BaseAlertService, AlertServiceMixin):
|
|||||||
try:
|
try:
|
||||||
self._checks_performed += 1
|
self._checks_performed += 1
|
||||||
|
|
||||||
query = """
|
# Use a simpler query with timeout and connection management
|
||||||
WITH capacity_analysis AS (
|
from sqlalchemy import text
|
||||||
SELECT
|
simplified_query = text("""
|
||||||
p.tenant_id,
|
SELECT
|
||||||
p.planned_date,
|
pb.tenant_id,
|
||||||
SUM(p.planned_quantity) as total_planned,
|
DATE(pb.planned_start_time) as planned_date,
|
||||||
MAX(pc.daily_capacity) as max_daily_capacity,
|
COUNT(*) as batch_count,
|
||||||
COUNT(DISTINCT p.equipment_id) as equipment_count,
|
SUM(pb.planned_quantity) as total_planned,
|
||||||
AVG(pc.efficiency_percent) as avg_efficiency,
|
'capacity_check' as capacity_status,
|
||||||
CASE
|
100.0 as capacity_percentage -- Default value for processing
|
||||||
WHEN SUM(p.planned_quantity) > MAX(pc.daily_capacity) * 1.2 THEN 'severe_overload'
|
FROM production_batches pb
|
||||||
WHEN SUM(p.planned_quantity) > MAX(pc.daily_capacity) THEN 'overload'
|
WHERE pb.planned_start_time >= CURRENT_DATE
|
||||||
WHEN SUM(p.planned_quantity) > MAX(pc.daily_capacity) * 0.9 THEN 'near_capacity'
|
AND pb.planned_start_time <= CURRENT_DATE + INTERVAL '3 days'
|
||||||
ELSE 'normal'
|
AND pb.status IN ('PLANNED', 'PENDING', 'IN_PROGRESS')
|
||||||
END as capacity_status,
|
GROUP BY pb.tenant_id, DATE(pb.planned_start_time)
|
||||||
(SUM(p.planned_quantity) / MAX(pc.daily_capacity)) * 100 as capacity_percentage
|
HAVING COUNT(*) > 10 -- Alert if more than 10 batches per day
|
||||||
FROM production_schedule p
|
ORDER BY total_planned DESC
|
||||||
JOIN production_capacity pc ON pc.equipment_id = p.equipment_id
|
LIMIT 20 -- Limit results to prevent excessive processing
|
||||||
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 timeout and proper session handling
|
||||||
try:
|
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:
|
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)
|
result = await session.execute(simplified_query)
|
||||||
capacity_issues = result.fetchall()
|
capacity_issues = result.fetchall()
|
||||||
|
|
||||||
for issue in capacity_issues:
|
for issue in capacity_issues:
|
||||||
await self._process_capacity_issue(issue.tenant_id, issue)
|
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:
|
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:
|
except Exception as e:
|
||||||
# Skip capacity checks if tables don't exist (graceful degradation)
|
# 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))
|
logger.debug("Capacity check skipped - missing tables", error=str(e))
|
||||||
else:
|
else:
|
||||||
logger.error("Capacity check failed", error=str(e))
|
logger.error("Capacity check failed", error=str(e))
|
||||||
@@ -216,8 +202,8 @@ class ProductionAlertService(BaseAlertService, AlertServiceMixin):
|
|||||||
try:
|
try:
|
||||||
self._checks_performed += 1
|
self._checks_performed += 1
|
||||||
|
|
||||||
# Simplified query without customer_orders dependency
|
# Simplified query with timeout and proper error handling
|
||||||
query = """
|
query = text("""
|
||||||
SELECT
|
SELECT
|
||||||
pb.id, pb.tenant_id, pb.product_name, pb.batch_number,
|
pb.id, pb.tenant_id, pb.product_name, pb.batch_number,
|
||||||
pb.planned_end_time as planned_completion_time, pb.actual_start_time,
|
pb.planned_end_time as planned_completion_time, pb.actual_start_time,
|
||||||
@@ -237,19 +223,29 @@ class ProductionAlertService(BaseAlertService, AlertServiceMixin):
|
|||||||
WHEN 'URGENT' THEN 1 WHEN 'HIGH' THEN 2 ELSE 3
|
WHEN 'URGENT' THEN 1 WHEN 'HIGH' THEN 2 ELSE 3
|
||||||
END,
|
END,
|
||||||
delay_minutes DESC
|
delay_minutes DESC
|
||||||
"""
|
LIMIT 50 -- Limit results to prevent excessive processing
|
||||||
|
""")
|
||||||
|
|
||||||
from sqlalchemy import text
|
try:
|
||||||
async with self.db_manager.get_session() as session:
|
from sqlalchemy import text
|
||||||
result = await session.execute(text(query))
|
async with self.db_manager.get_session() as session:
|
||||||
delays = result.fetchall()
|
# Set statement timeout
|
||||||
|
await session.execute(text("SET statement_timeout = '30s'"))
|
||||||
|
result = await session.execute(query)
|
||||||
|
delays = result.fetchall()
|
||||||
|
|
||||||
for delay in delays:
|
for delay in delays:
|
||||||
await self._process_production_delay(delay)
|
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:
|
except Exception as e:
|
||||||
# Skip delay checks if tables don't exist (graceful degradation)
|
# 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))
|
logger.debug("Production delay check skipped - missing tables", error=str(e))
|
||||||
else:
|
else:
|
||||||
logger.error("Production delay check failed", error=str(e))
|
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 uuid import UUID
|
||||||
|
|
||||||
from app.services.subscription_limit_service import SubscriptionLimitService
|
from app.services.subscription_limit_service import SubscriptionLimitService
|
||||||
|
from app.services.payment_service import PaymentService
|
||||||
from app.repositories import SubscriptionRepository
|
from app.repositories import SubscriptionRepository
|
||||||
from app.models.tenants import Subscription
|
from app.models.tenants import Subscription
|
||||||
from shared.auth.decorators import get_current_user_dep, require_admin_role_dep
|
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))
|
logger.error("Failed to create subscription limit service", error=str(e))
|
||||||
raise HTTPException(status_code=500, detail="Service initialization failed")
|
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():
|
def get_subscription_repository():
|
||||||
try:
|
try:
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
@@ -182,7 +190,7 @@ async def validate_plan_upgrade(
|
|||||||
"""Validate if tenant can upgrade to a new plan"""
|
"""Validate if tenant can upgrade to a new plan"""
|
||||||
|
|
||||||
try:
|
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)
|
result = await limit_service.validate_plan_upgrade(str(tenant_id), new_plan)
|
||||||
return result
|
return result
|
||||||
|
|
||||||
@@ -241,9 +249,9 @@ async def upgrade_subscription_plan(
|
|||||||
detail="Failed to upgrade subscription plan"
|
detail="Failed to upgrade subscription plan"
|
||||||
)
|
)
|
||||||
|
|
||||||
@router.get("/plans/available")
|
@router.get("/plans")
|
||||||
async def get_available_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:
|
try:
|
||||||
# This could be moved to a config service or database
|
# 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",
|
"description": "Ideal para cadenas con obradores centrales",
|
||||||
"monthly_price": 399.0,
|
"monthly_price": 399.0,
|
||||||
"max_users": -1, # Unlimited
|
"max_users": -1, # Unlimited
|
||||||
"max_locations": -1, # Unlimited
|
"max_locations": -1, # Unlimited
|
||||||
"max_products": -1, # Unlimited
|
"max_products": -1, # Unlimited
|
||||||
"features": {
|
"features": {
|
||||||
"inventory_management": "multi_location",
|
"inventory_management": "multi_location",
|
||||||
@@ -322,3 +330,92 @@ async def get_available_plans():
|
|||||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
detail="Failed to get available plans"
|
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"
|
||||||
|
)
|
||||||
@@ -67,4 +67,9 @@ class TenantSettings(BaseServiceSettings):
|
|||||||
DATA_EXPORT_ENABLED: bool = True
|
DATA_EXPORT_ENABLED: bool = True
|
||||||
DATA_DELETION_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()
|
settings = TenantSettings()
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ from fastapi.middleware.cors import CORSMiddleware
|
|||||||
|
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
from app.core.database import database_manager
|
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.logging import setup_logging
|
||||||
from shared.monitoring.metrics import MetricsCollector
|
from shared.monitoring.metrics import MetricsCollector
|
||||||
|
|
||||||
@@ -42,6 +42,7 @@ app.add_middleware(
|
|||||||
# Include routers
|
# Include routers
|
||||||
app.include_router(tenants.router, prefix="/api/v1", tags=["tenants"])
|
app.include_router(tenants.router, prefix="/api/v1", tags=["tenants"])
|
||||||
app.include_router(subscriptions.router, prefix="/api/v1", tags=["subscriptions"])
|
app.include_router(subscriptions.router, prefix="/api/v1", tags=["subscriptions"])
|
||||||
|
app.include_router(webhooks.router, prefix="/api/v1", tags=["webhooks"])
|
||||||
|
|
||||||
@app.on_event("startup")
|
@app.on_event("startup")
|
||||||
async def startup_event():
|
async def startup_event():
|
||||||
|
|||||||
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
|
||||||
@@ -14,3 +14,4 @@ pytz==2023.3
|
|||||||
python-logstash==0.4.8
|
python-logstash==0.4.8
|
||||||
structlog==23.2.0
|
structlog==23.2.0
|
||||||
python-jose[cryptography]==3.3.0
|
python-jose[cryptography]==3.3.0
|
||||||
|
stripe==7.4.0
|
||||||
|
|||||||
@@ -145,7 +145,7 @@ class BaseAlertService:
|
|||||||
|
|
||||||
# PATTERN 3: Database Triggers
|
# PATTERN 3: Database Triggers
|
||||||
async def start_database_listener(self):
|
async def start_database_listener(self):
|
||||||
"""Listen for database notifications"""
|
"""Listen for database notifications with connection management"""
|
||||||
try:
|
try:
|
||||||
import asyncpg
|
import asyncpg
|
||||||
# Convert SQLAlchemy URL format to plain PostgreSQL for asyncpg
|
# Convert SQLAlchemy URL format to plain PostgreSQL for asyncpg
|
||||||
@@ -153,16 +153,51 @@ class BaseAlertService:
|
|||||||
if database_url.startswith('postgresql+asyncpg://'):
|
if database_url.startswith('postgresql+asyncpg://'):
|
||||||
database_url = database_url.replace('postgresql+asyncpg://', 'postgresql://')
|
database_url = database_url.replace('postgresql+asyncpg://', 'postgresql://')
|
||||||
|
|
||||||
conn = await asyncpg.connect(database_url)
|
# Add connection timeout and retry logic
|
||||||
|
max_retries = 3
|
||||||
|
retry_count = 0
|
||||||
|
conn = None
|
||||||
|
|
||||||
# Register listeners based on service
|
while retry_count < max_retries and not conn:
|
||||||
await self.register_db_listeners(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
|
||||||
|
|
||||||
logger.info("Database listeners registered", service=self.config.SERVICE_NAME)
|
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:
|
except Exception as e:
|
||||||
logger.error("Failed to setup database listeners", service=self.config.SERVICE_NAME, error=str(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):
|
async def register_db_listeners(self, conn):
|
||||||
"""Register database listeners - Override in service"""
|
"""Register database listeners - Override in service"""
|
||||||
pass
|
pass
|
||||||
|
|||||||
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_BASE_URL: str = os.getenv("WHATSAPP_BASE_URL", "https://api.twilio.com")
|
||||||
WHATSAPP_FROM_NUMBER: str = os.getenv("WHATSAPP_FROM_NUMBER", "")
|
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
|
# ML & AI CONFIGURATION
|
||||||
# ================================================================
|
# ================================================================
|
||||||
|
|||||||
Reference in New Issue
Block a user