Support subcription payments

This commit is contained in:
Urtzi Alfaro
2025-09-25 14:30:47 +02:00
parent f02a980c87
commit 89b75bd7af
22 changed files with 2119 additions and 364 deletions

View File

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

View File

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

View File

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

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

View File

@@ -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,6 +52,12 @@ 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';
@@ -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);
@@ -117,6 +149,16 @@ export const RegisterForm: React.FC<RegisterFormProps> = ({
} }
}; };
const handlePaymentSuccess = () => {
handleRegistrationSubmit(); // In a real app, you would pass the payment method ID
};
const handlePaymentError = (errorMessage: string) => {
showErrorToast(errorMessage, {
title: 'Error en el pago'
});
};
const handleInputChange = (field: keyof SimpleUserRegistration) => (e: React.ChangeEvent<HTMLInputElement>) => { const handleInputChange = (field: keyof SimpleUserRegistration) => (e: React.ChangeEvent<HTMLInputElement>) => {
const value = e.target.type === 'checkbox' ? e.target.checked : e.target.value; const value = e.target.type === 'checkbox' ? e.target.checked : e.target.value;
setFormData(prev => ({ ...prev, [field]: value })); setFormData(prev => ({ ...prev, [field]: value }));
@@ -125,8 +167,39 @@ export const RegisterForm: React.FC<RegisterFormProps> = ({
} }
}; };
// 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 ( return (
<Card className={`p-8 w-full max-w-md ${className || ''}`} role="main"> <div className="space-y-6">
<div className="text-center mb-8"> <div className="text-center mb-8">
<h1 className="text-3xl font-bold text-text-primary mb-2"> <h1 className="text-3xl font-bold text-text-primary mb-2">
{t('auth:register.title', 'Crear Cuenta')} {t('auth:register.title', 'Crear Cuenta')}
@@ -134,9 +207,12 @@ export const RegisterForm: React.FC<RegisterFormProps> = ({
<p className="text-text-secondary text-lg"> <p className="text-text-secondary text-lg">
{t('auth:register.subtitle', 'Únete y comienza hoy mismo')} {t('auth:register.subtitle', 'Únete y comienza hoy mismo')}
</p> </p>
<p className="text-sm text-text-tertiary mt-2">
Paso 1 de 3: Información Básica
</p>
</div> </div>
<form onSubmit={handleSubmit} className="space-y-6"> <form onSubmit={(e) => { e.preventDefault(); handleNextStep(); }} className="space-y-6">
<Input <Input
label={t('auth:register.first_name', 'Nombre Completo')} label={t('auth:register.first_name', 'Nombre Completo')}
placeholder="Juan Pérez García" placeholder="Juan Pérez García"
@@ -165,7 +241,7 @@ export const RegisterForm: React.FC<RegisterFormProps> = ({
autoComplete="email" autoComplete="email"
leftIcon={ leftIcon={
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <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" /> <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> </svg>
} }
/> />
@@ -194,7 +270,7 @@ export const RegisterForm: React.FC<RegisterFormProps> = ({
aria-label={showPassword ? 'Ocultar contraseña' : 'Mostrar contraseña'} aria-label={showPassword ? 'Ocultar contraseña' : 'Mostrar contraseña'}
> >
{showPassword ? ( {showPassword ? (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <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" /> <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>
) : ( ) : (
@@ -320,34 +396,124 @@ export const RegisterForm: React.FC<RegisterFormProps> = ({
)} )}
</div> </div>
<div className="flex justify-between pt-4">
<div></div> {/* Spacer for alignment */}
<Button <Button
type="submit" type="submit"
variant="primary" variant="primary"
size="lg" size="lg"
isLoading={isLoading}
loadingText="Creando cuenta..."
disabled={isLoading || !validatePassword(formData.password) || !formData.acceptTerms || passwordMatchStatus !== 'match'} disabled={isLoading || !validatePassword(formData.password) || !formData.acceptTerms || passwordMatchStatus !== 'match'}
className="w-full" className="w-48"
onClick={(e) => {
console.log('Button clicked!');
// Let form submit handle it naturally
}}
> >
Crear Cuenta Siguiente
</Button> </Button>
</div>
</form>
</div>
);
{error && ( case 'subscription':
<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"> return (
<div className="space-y-6">
<div className="text-center mb-8">
<h1 className="text-3xl font-bold text-text-primary mb-2">
{t('auth:subscription.select_plan', 'Selecciona tu plan')}
</h1>
<p className="text-text-secondary text-lg">
{t('auth:subscription.choose_plan', 'Elige el plan que mejor se adapte a tu negocio')}
</p>
<p className="text-sm text-text-tertiary mt-2">
Paso 2 de 3: Plan de Suscripción
</p>
</div>
<SubscriptionSelection
selectedPlan={selectedPlan}
onPlanSelect={setSelectedPlan}
showTrialOption={true}
onTrialSelect={setUseTrial}
trialSelected={useTrial}
/>
<div className="flex justify-between pt-4">
<Button
variant="outline"
size="lg"
onClick={handlePreviousStep}
disabled={isLoading}
className="w-48"
>
Anterior
</Button>
<Button
variant="primary"
size="lg"
onClick={handleNextStep}
disabled={isLoading}
className="w-48"
>
Siguiente
</Button>
</div>
</div>
);
case 'payment':
return (
<div className="space-y-6">
<div className="text-center mb-8">
<h1 className="text-3xl font-bold text-text-primary mb-2">
{t('auth:payment.payment_info', 'Información de Pago')}
</h1>
<p className="text-text-secondary text-lg">
{t('auth:payment.secure_payment', 'Tu información de pago está protegida con encriptación de extremo a extremo')}
</p>
<p className="text-sm text-text-tertiary mt-2">
Paso 3 de 3: Procesamiento de Pago
</p>
</div>
<Elements stripe={stripePromise}>
<PaymentForm
onPaymentSuccess={handlePaymentSuccess}
onPaymentError={handlePaymentError}
bypassPayment={bypassPayment}
onBypassToggle={() => setBypassPayment(!bypassPayment)}
/>
</Elements>
<div className="flex justify-between pt-4">
<Button
variant="outline"
size="lg"
onClick={handlePreviousStep}
disabled={isLoading}
className="w-48"
>
Anterior
</Button>
</div>
</div>
);
}
};
return (
<Card className={`p-8 w-full max-w-3xl ${className || ''}`} role="main">
{renderStepIndicator()}
{renderCurrentStep()}
{error && currentStep !== 'payment' && (
<div className="bg-color-error/10 border border-color-error/20 text-color-error px-4 py-3 rounded-lg text-sm flex items-start space-x-3 mt-4" role="alert">
<svg className="w-5 h-5 mt-0.5 flex-shrink-0" fill="currentColor" viewBox="0 0 20 20"> <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" /> <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> </svg>
<span>{error}</span> <span>{error}</span>
</div> </div>
)} )}
</form>
{/* Login Link */} {/* Login Link - only show on first step */}
{onLoginClick && ( {onLoginClick && currentStep === 'basic_info' && (
<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?

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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,46 +92,16 @@ 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 (
SELECT
p.tenant_id,
p.planned_date,
SUM(p.planned_quantity) as total_planned,
MAX(pc.daily_capacity) as max_daily_capacity,
COUNT(DISTINCT p.equipment_id) as equipment_count,
AVG(pc.efficiency_percent) as avg_efficiency,
CASE
WHEN SUM(p.planned_quantity) > MAX(pc.daily_capacity) * 1.2 THEN 'severe_overload'
WHEN SUM(p.planned_quantity) > MAX(pc.daily_capacity) THEN 'overload'
WHEN SUM(p.planned_quantity) > MAX(pc.daily_capacity) * 0.9 THEN 'near_capacity'
ELSE 'normal'
END as capacity_status,
(SUM(p.planned_quantity) / MAX(pc.daily_capacity)) * 100 as capacity_percentage
FROM production_schedule p
JOIN production_capacity pc ON pc.equipment_id = p.equipment_id
WHERE p.planned_date >= CURRENT_DATE
AND p.planned_date <= CURRENT_DATE + INTERVAL '3 days'
AND p.status IN ('PENDING', 'IN_PROGRESS')
AND p.tenant_id = $1
GROUP BY p.tenant_id, p.planned_date
)
SELECT * FROM capacity_analysis
WHERE capacity_status != 'normal'
ORDER BY capacity_percentage DESC
"""
# Check production capacity without tenant dependencies
try:
from sqlalchemy import text from sqlalchemy import text
# Simplified query using only existing production tables
simplified_query = text(""" simplified_query = text("""
SELECT SELECT
pb.tenant_id, pb.tenant_id,
DATE(pb.planned_start_time) as planned_date, DATE(pb.planned_start_time) as planned_date,
COUNT(*) as batch_count, COUNT(*) as batch_count,
SUM(pb.planned_quantity) as total_planned, SUM(pb.planned_quantity) as total_planned,
'capacity_check' as capacity_status 'capacity_check' as capacity_status,
100.0 as capacity_percentage -- Default value for processing
FROM production_batches pb FROM production_batches pb
WHERE pb.planned_start_time >= CURRENT_DATE WHERE pb.planned_start_time >= CURRENT_DATE
AND pb.planned_start_time <= CURRENT_DATE + INTERVAL '3 days' AND pb.planned_start_time <= CURRENT_DATE + INTERVAL '3 days'
@@ -131,21 +109,29 @@ class ProductionAlertService(BaseAlertService, AlertServiceMixin):
GROUP BY pb.tenant_id, DATE(pb.planned_start_time) GROUP BY pb.tenant_id, DATE(pb.planned_start_time)
HAVING COUNT(*) > 10 -- Alert if more than 10 batches per day HAVING COUNT(*) > 10 -- Alert if more than 10 batches per day
ORDER BY total_planned DESC ORDER BY total_planned DESC
LIMIT 20 -- Limit results to prevent excessive processing
""") """)
# Use timeout and proper session handling
try:
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
""")
try:
from sqlalchemy import text from sqlalchemy import text
async with self.db_manager.get_session() as session: async with self.db_manager.get_session() as session:
result = await session.execute(text(query)) # Set statement timeout
await session.execute(text("SET statement_timeout = '30s'"))
result = await session.execute(query)
delays = result.fetchall() 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))

View File

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

View 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"
)

View File

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

View File

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

View 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

View File

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

View File

@@ -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
while retry_count < max_retries and not conn:
try:
conn = await asyncio.wait_for(
asyncpg.connect(database_url),
timeout=10.0
)
break
except (asyncio.TimeoutError, Exception) as e:
retry_count += 1
if retry_count < max_retries:
logger.warning(f"DB listener connection attempt {retry_count} failed, retrying...",
service=self.config.SERVICE_NAME, error=str(e))
await asyncio.sleep(2)
else:
raise
if conn:
# Register listeners based on service # Register listeners based on service
await self.register_db_listeners(conn) await self.register_db_listeners(conn)
logger.info("Database listeners registered", service=self.config.SERVICE_NAME) 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

View 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

View 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

View File

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