Add subcription feature 9
This commit is contained in:
@@ -23,6 +23,9 @@ Before you begin testing, ensure you have:
|
|||||||
- ✅ Database configured and accessible
|
- ✅ Database configured and accessible
|
||||||
- ✅ Redis instance running (for caching)
|
- ✅ Redis instance running (for caching)
|
||||||
|
|
||||||
|
|
||||||
|
stripe listen --forward-to https://bakery-ia.local/api/v1/webhooks/stripe --skip-verify
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Environment Setup
|
## Environment Setup
|
||||||
|
|||||||
@@ -35,7 +35,16 @@ server {
|
|||||||
# The frontend makes requests to /api which are routed by the ingress controller
|
# The frontend makes requests to /api which are routed by the ingress controller
|
||||||
|
|
||||||
# Static assets with aggressive caching (including source maps for debugging)
|
# Static assets with aggressive caching (including source maps for debugging)
|
||||||
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot|map)$ {
|
location ~* ^/assets/.*\.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot|map)$ {
|
||||||
|
expires 1y;
|
||||||
|
add_header Cache-Control "public, immutable";
|
||||||
|
add_header Vary Accept-Encoding;
|
||||||
|
access_log off;
|
||||||
|
try_files $uri =404;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Also handle JS and CSS files anywhere in the structure (for dynamic imports)
|
||||||
|
location ~* \.(js|css)$ {
|
||||||
expires 1y;
|
expires 1y;
|
||||||
add_header Cache-Control "public, immutable";
|
add_header Cache-Control "public, immutable";
|
||||||
add_header Vary Accept-Encoding;
|
add_header Vary Accept-Encoding;
|
||||||
|
|||||||
@@ -252,8 +252,12 @@ export class SubscriptionService {
|
|||||||
return apiClient.get<PlanUpgradeValidation>(`/tenants/${tenantId}/subscription/validate-upgrade/${planKey}`);
|
return apiClient.get<PlanUpgradeValidation>(`/tenants/${tenantId}/subscription/validate-upgrade/${planKey}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
async upgradePlan(tenantId: string, planKey: string): Promise<PlanUpgradeResult> {
|
async upgradePlan(tenantId: string, planKey: string, billingCycle: BillingCycle = 'monthly'): Promise<PlanUpgradeResult> {
|
||||||
return apiClient.post<PlanUpgradeResult>(`/tenants/${tenantId}/subscription/upgrade`, { new_plan: planKey });
|
// The backend expects new_plan and billing_cycle as query parameters
|
||||||
|
return apiClient.post<PlanUpgradeResult>(
|
||||||
|
`/tenants/${tenantId}/subscription/upgrade?new_plan=${planKey}&billing_cycle=${billingCycle}`,
|
||||||
|
{}
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async canAddLocation(tenantId: string): Promise<{ can_add: boolean; reason?: string; current_count?: number; max_allowed?: number }> {
|
async canAddLocation(tenantId: string): Promise<{ can_add: boolean; reason?: string; current_count?: number; max_allowed?: number }> {
|
||||||
|
|||||||
@@ -261,11 +261,16 @@ export interface PlanUpgradeResult {
|
|||||||
success: boolean;
|
success: boolean;
|
||||||
message: string;
|
message: string;
|
||||||
new_plan: SubscriptionTier;
|
new_plan: SubscriptionTier;
|
||||||
effective_date: string;
|
effective_date?: string;
|
||||||
old_plan?: string;
|
old_plan?: string;
|
||||||
new_monthly_price?: number;
|
new_monthly_price?: number;
|
||||||
validation?: any;
|
validation?: any;
|
||||||
requires_token_refresh?: boolean; // Backend signals that token should be refreshed
|
requires_token_refresh?: boolean; // Backend signals that token should be refreshed
|
||||||
|
// Trial handling fields
|
||||||
|
is_trialing?: boolean;
|
||||||
|
trial_ends_at?: string;
|
||||||
|
stripe_updated?: boolean;
|
||||||
|
trial_preserved?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SubscriptionInvoice {
|
export interface SubscriptionInvoice {
|
||||||
|
|||||||
@@ -5,6 +5,13 @@ import { DialogModal } from '../../components/ui/DialogModal/DialogModal';
|
|||||||
import { subscriptionService } from '../../api';
|
import { subscriptionService } from '../../api';
|
||||||
import { showToast } from '../../utils/toast';
|
import { showToast } from '../../utils/toast';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { loadStripe, Stripe, StripeElements } from '@stripe/stripe-js';
|
||||||
|
import {
|
||||||
|
Elements,
|
||||||
|
CardElement,
|
||||||
|
useStripe as useStripeHook,
|
||||||
|
useElements as useElementsHook
|
||||||
|
} from '@stripe/react-stripe-js';
|
||||||
|
|
||||||
interface PaymentMethodUpdateModalProps {
|
interface PaymentMethodUpdateModalProps {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
@@ -24,6 +31,277 @@ interface PaymentMethodUpdateModalProps {
|
|||||||
}) => void;
|
}) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Get Stripe publishable key from environment with proper fallback
|
||||||
|
const getStripePublishableKey = (): string => {
|
||||||
|
// Try runtime config first (for production)
|
||||||
|
if (typeof window !== 'undefined' && (window as any).__RUNTIME_CONFIG__?.VITE_STRIPE_PUBLISHABLE_KEY) {
|
||||||
|
return (window as any).__RUNTIME_CONFIG__.VITE_STRIPE_PUBLISHABLE_KEY;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try build-time env variable
|
||||||
|
if (import.meta.env.VITE_STRIPE_PUBLISHABLE_KEY) {
|
||||||
|
return import.meta.env.VITE_STRIPE_PUBLISHABLE_KEY;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback for development - should not be used in production
|
||||||
|
console.warn('Stripe publishable key not found in environment. Using test key for development.');
|
||||||
|
return 'pk_test_51QuxKyIzCdnBmAVTGM8fvXYkItrBUILz6lHYwhAva6ZAH1HRi0e8zDRgZ4X3faN0zEABp5RHjCVBmMJL3aKXbaC200fFrSNnPl';
|
||||||
|
};
|
||||||
|
|
||||||
|
// Create Stripe promise for Elements provider
|
||||||
|
const stripePromise = loadStripe(getStripePublishableKey(), {
|
||||||
|
betas: ['elements_v2'],
|
||||||
|
locale: 'auto',
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stripe Card Form Component
|
||||||
|
* This component handles the actual Stripe card input and submission
|
||||||
|
*/
|
||||||
|
const StripeCardForm = ({
|
||||||
|
tenantId,
|
||||||
|
onSuccess,
|
||||||
|
onError,
|
||||||
|
onLoading
|
||||||
|
}: {
|
||||||
|
tenantId: string;
|
||||||
|
onSuccess: (paymentMethod: any) => void;
|
||||||
|
onError: (error: string) => void;
|
||||||
|
onLoading: (loading: boolean) => void;
|
||||||
|
}) => {
|
||||||
|
const stripe = useStripeHook();
|
||||||
|
const elements = useElementsHook();
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [authenticating, setAuthenticating] = useState(false);
|
||||||
|
|
||||||
|
// Check if elements are ready
|
||||||
|
const [elementsReady, setElementsReady] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (elements) {
|
||||||
|
const checkElementsReady = async () => {
|
||||||
|
try {
|
||||||
|
// Wait a moment for elements to be fully ready
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 50));
|
||||||
|
setElementsReady(true);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Elements not ready:', err);
|
||||||
|
setError('Payment form not ready. Please try again.');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
checkElementsReady();
|
||||||
|
}
|
||||||
|
}, [elements]);
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
if (!stripe || !elements || !elementsReady) {
|
||||||
|
setError('Payment processor not loaded');
|
||||||
|
onError('Payment processor not loaded');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
onLoading(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Trigger form validation and wallet collection
|
||||||
|
const { error: submitError } = await elements.submit();
|
||||||
|
|
||||||
|
if (submitError) {
|
||||||
|
setError(submitError.message || 'Failed to validate payment information');
|
||||||
|
setLoading(false);
|
||||||
|
onLoading(false);
|
||||||
|
onError(submitError.message || 'Failed to validate payment information');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create payment method using Stripe Elements
|
||||||
|
const cardElement = elements.getElement(CardElement);
|
||||||
|
const { paymentMethod, error: stripeError } = await stripe.createPaymentMethod({
|
||||||
|
type: 'card',
|
||||||
|
card: cardElement,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (stripeError) {
|
||||||
|
setError(stripeError.message || 'Failed to create payment method');
|
||||||
|
setLoading(false);
|
||||||
|
onLoading(false);
|
||||||
|
onError(stripeError.message || 'Failed to create payment method');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!paymentMethod || !paymentMethod.id) {
|
||||||
|
setError('No payment method created');
|
||||||
|
setLoading(false);
|
||||||
|
onLoading(false);
|
||||||
|
onError('No payment method created');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Call backend to update payment method
|
||||||
|
const result = await subscriptionService.updatePaymentMethod(tenantId, paymentMethod.id);
|
||||||
|
|
||||||
|
// Handle 3D Secure authentication if required
|
||||||
|
if (result.requires_action && result.client_secret) {
|
||||||
|
setAuthenticating(true);
|
||||||
|
setLoading(false);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Handle 3D Secure authentication
|
||||||
|
const { error: confirmError, setupIntent } = await stripe.confirmCardSetup(result.client_secret);
|
||||||
|
|
||||||
|
if (confirmError) {
|
||||||
|
setError(confirmError.message || '3D Secure authentication failed');
|
||||||
|
setAuthenticating(false);
|
||||||
|
onLoading(false);
|
||||||
|
onError(confirmError.message || '3D Secure authentication failed');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (setupIntent && setupIntent.status === 'succeeded') {
|
||||||
|
onSuccess({
|
||||||
|
brand: result.brand,
|
||||||
|
last4: result.last4,
|
||||||
|
exp_month: result.exp_month,
|
||||||
|
exp_year: result.exp_year,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
setError('3D Secure authentication completed but payment method not confirmed');
|
||||||
|
setAuthenticating(false);
|
||||||
|
onLoading(false);
|
||||||
|
onError('3D Secure authentication completed but payment method not confirmed');
|
||||||
|
}
|
||||||
|
} catch (authError) {
|
||||||
|
console.error('Error during 3D Secure authentication:', authError);
|
||||||
|
const errorMessage = authError instanceof Error
|
||||||
|
? authError.message
|
||||||
|
: '3D Secure authentication error. Please try again.';
|
||||||
|
setError(errorMessage);
|
||||||
|
setAuthenticating(false);
|
||||||
|
onLoading(false);
|
||||||
|
onError(errorMessage);
|
||||||
|
}
|
||||||
|
} else if (result.success) {
|
||||||
|
// No authentication required
|
||||||
|
onSuccess({
|
||||||
|
brand: result.brand,
|
||||||
|
last4: result.last4,
|
||||||
|
exp_month: result.exp_month,
|
||||||
|
exp_year: result.exp_year,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// Handle different payment intent statuses
|
||||||
|
let errorMessage = result.message || 'Failed to update payment method';
|
||||||
|
if (result.payment_intent_status === 'requires_payment_method') {
|
||||||
|
errorMessage = 'Your card was declined. Please try a different payment method.';
|
||||||
|
} else if (result.payment_intent_status === 'requires_action') {
|
||||||
|
errorMessage = 'Authentication required but client secret missing. Please try again.';
|
||||||
|
} else if (result.payment_intent_status === 'processing') {
|
||||||
|
errorMessage = 'Payment is processing. Please wait a few minutes and try again.';
|
||||||
|
} else if (result.payment_intent_status === 'canceled') {
|
||||||
|
errorMessage = 'Payment was canceled. Please try again.';
|
||||||
|
}
|
||||||
|
|
||||||
|
setError(errorMessage);
|
||||||
|
setLoading(false);
|
||||||
|
onLoading(false);
|
||||||
|
onError(errorMessage);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error updating payment method:', err);
|
||||||
|
const errorMessage = err instanceof Error
|
||||||
|
? err.message
|
||||||
|
: 'An unexpected error occurred while updating payment method.';
|
||||||
|
setError(errorMessage);
|
||||||
|
setLoading(false);
|
||||||
|
onLoading(false);
|
||||||
|
onError(errorMessage);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-[var(--text-primary)] mb-2">
|
||||||
|
Nueva Tarjeta
|
||||||
|
</label>
|
||||||
|
<div className="p-3 border border-[var(--border-primary)] rounded-lg bg-[var(--bg-primary)] min-h-[50px]">
|
||||||
|
{!elementsReady ? (
|
||||||
|
<div className="flex items-center gap-2 text-[var(--text-secondary)] h-[50px]">
|
||||||
|
<Loader2 className="w-4 h-4 animate-spin" />
|
||||||
|
<span>Preparando formulario de pago...</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<CardElement
|
||||||
|
options={
|
||||||
|
{
|
||||||
|
style: {
|
||||||
|
base: {
|
||||||
|
fontSize: '16px',
|
||||||
|
color: '#32325d',
|
||||||
|
'::placeholder': {
|
||||||
|
color: '#aab7c4',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
invalid: {
|
||||||
|
color: '#fa755a',
|
||||||
|
iconColor: '#fa755a',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 3D Secure Authentication Status */}
|
||||||
|
{authenticating && (
|
||||||
|
<div className="p-3 bg-blue-500/10 border border-blue-500/20 rounded-lg flex items-center gap-2 text-blue-500">
|
||||||
|
<CreditCard className="w-4 h-4" />
|
||||||
|
<span className="text-sm">Completing secure authentication with your bank...</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Error Display */}
|
||||||
|
{error && (
|
||||||
|
<div className="p-3 bg-red-500/10 border border-red-500/20 rounded-lg flex items-center gap-2 text-red-500">
|
||||||
|
<AlertCircle className="w-4 h-4" />
|
||||||
|
<span className="text-sm">{error}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Action Buttons */}
|
||||||
|
<div className="flex gap-3 justify-end">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
disabled={loading || authenticating}
|
||||||
|
>
|
||||||
|
Cancelar
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
variant="primary"
|
||||||
|
disabled={loading || authenticating}
|
||||||
|
className="flex items-center gap-2"
|
||||||
|
>
|
||||||
|
{(loading || authenticating) && <Loader2 className="w-4 h-4 animate-spin" />}
|
||||||
|
{authenticating ? 'Autenticando...' : loading ? 'Procesando...' : 'Actualizar Método de Pago'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Main Payment Method Update Modal Component
|
||||||
|
*/
|
||||||
export const PaymentMethodUpdateModal: React.FC<PaymentMethodUpdateModalProps> = ({
|
export const PaymentMethodUpdateModal: React.FC<PaymentMethodUpdateModalProps> = ({
|
||||||
isOpen,
|
isOpen,
|
||||||
onClose,
|
onClose,
|
||||||
@@ -33,271 +311,62 @@ export const PaymentMethodUpdateModal: React.FC<PaymentMethodUpdateModalProps> =
|
|||||||
}) => {
|
}) => {
|
||||||
const { t } = useTranslation('subscription');
|
const { t } = useTranslation('subscription');
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [authenticating, setAuthenticating] = useState(false);
|
|
||||||
const [paymentMethodId, setPaymentMethodId] = useState('');
|
|
||||||
const [stripe, setStripe] = useState<any>(null);
|
|
||||||
const [elements, setElements] = useState<any>(null);
|
|
||||||
const [cardElement, setCardElement] = useState<any>(null);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
const [success, setSuccess] = useState(false);
|
const [success, setSuccess] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [stripeLoaded, setStripeLoaded] = useState(false);
|
||||||
|
const [stripeError, setStripeError] = useState<string | null>(null);
|
||||||
|
|
||||||
// Load Stripe.js dynamically
|
const handleSuccess = (paymentMethod: any) => {
|
||||||
|
setSuccess(true);
|
||||||
|
showToast.success('Payment method updated successfully');
|
||||||
|
onPaymentMethodUpdated(paymentMethod);
|
||||||
|
|
||||||
|
// Close modal after a brief delay to show success message
|
||||||
|
setTimeout(() => {
|
||||||
|
handleClose();
|
||||||
|
}, 2000);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleError = (errorMessage: string) => {
|
||||||
|
setError(errorMessage);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleLoading = (isLoading: boolean) => {
|
||||||
|
setLoading(isLoading);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Track Stripe loading status
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isOpen) {
|
if (isOpen) {
|
||||||
const loadStripe = async () => {
|
setStripeLoaded(false);
|
||||||
|
setStripeError(null);
|
||||||
|
|
||||||
|
// Check if Stripe is already loaded
|
||||||
|
const checkStripeLoaded = async () => {
|
||||||
try {
|
try {
|
||||||
// Get Stripe publishable key from runtime config or build-time env
|
// Give Stripe a moment to load
|
||||||
const getStripePublishableKey = () => {
|
await new Promise(resolve => setTimeout(resolve, 100));
|
||||||
if (typeof window !== 'undefined' && (window as any).__RUNTIME_CONFIG__?.VITE_STRIPE_PUBLISHABLE_KEY) {
|
|
||||||
return (window as any).__RUNTIME_CONFIG__.VITE_STRIPE_PUBLISHABLE_KEY;
|
|
||||||
}
|
|
||||||
return import.meta.env.VITE_STRIPE_PUBLISHABLE_KEY;
|
|
||||||
};
|
|
||||||
|
|
||||||
const stripeKey = getStripePublishableKey();
|
// Check if the Stripe promise resolves successfully
|
||||||
if (!stripeKey) {
|
const stripe = await stripePromise;
|
||||||
throw new Error('Stripe publishable key not configured');
|
if (stripe) {
|
||||||
|
setStripeLoaded(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load Stripe.js from CDN
|
|
||||||
const stripeScript = document.createElement('script');
|
|
||||||
stripeScript.src = 'https://js.stripe.com/v3/';
|
|
||||||
stripeScript.async = true;
|
|
||||||
stripeScript.onload = () => {
|
|
||||||
const stripeInstance = (window as any).Stripe(stripeKey);
|
|
||||||
setStripe(stripeInstance);
|
|
||||||
const elementsInstance = stripeInstance.elements();
|
|
||||||
setElements(elementsInstance);
|
|
||||||
};
|
|
||||||
document.body.appendChild(stripeScript);
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to load Stripe:', err);
|
console.error('Failed to load Stripe:', err);
|
||||||
setError('Failed to load payment processor');
|
setStripeError('Failed to load payment processor. Please check your connection.');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
loadStripe();
|
checkStripeLoaded();
|
||||||
}
|
}
|
||||||
}, [isOpen]);
|
}, [isOpen]);
|
||||||
|
|
||||||
// Create card element when Stripe and elements are loaded
|
|
||||||
useEffect(() => {
|
|
||||||
if (elements && !cardElement) {
|
|
||||||
const card = elements.create('card', {
|
|
||||||
style: {
|
|
||||||
base: {
|
|
||||||
fontSize: '16px',
|
|
||||||
color: '#32325d',
|
|
||||||
'::placeholder': {
|
|
||||||
color: '#aab7c4',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
invalid: {
|
|
||||||
color: '#fa755a',
|
|
||||||
iconColor: '#fa755a',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// Mount the card element
|
|
||||||
const cardElementContainer = document.getElementById('card-element');
|
|
||||||
if (cardElementContainer) {
|
|
||||||
card.mount(cardElementContainer);
|
|
||||||
setCardElement(card);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [elements, cardElement]);
|
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
|
||||||
e.preventDefault();
|
|
||||||
|
|
||||||
if (!stripe || !cardElement) {
|
|
||||||
setError('Payment processor not loaded');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setLoading(true);
|
|
||||||
setError(null);
|
|
||||||
setSuccess(false);
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Submit the card element to validate all inputs
|
|
||||||
const { error: submitError } = await elements.submit();
|
|
||||||
|
|
||||||
if (submitError) {
|
|
||||||
setError(submitError.message || 'Failed to validate payment information');
|
|
||||||
setLoading(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create payment method using Stripe Elements
|
|
||||||
const { paymentMethod, error: stripeError } = await stripe.createPaymentMethod({
|
|
||||||
type: 'card',
|
|
||||||
card: cardElement,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (stripeError) {
|
|
||||||
setError(stripeError.message || 'Failed to create payment method');
|
|
||||||
setLoading(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!paymentMethod || !paymentMethod.id) {
|
|
||||||
setError('No payment method created');
|
|
||||||
setLoading(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Call backend to update payment method
|
|
||||||
const result = await subscriptionService.updatePaymentMethod(tenantId, paymentMethod.id);
|
|
||||||
|
|
||||||
// Log 3DS requirement status
|
|
||||||
if (result.requires_action) {
|
|
||||||
console.log('3DS authentication required for payment method update', {
|
|
||||||
payment_method_id: paymentMethod.id,
|
|
||||||
setup_intent_id: result.payment_intent_id,
|
|
||||||
action_required: result.requires_action
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (result.success) {
|
|
||||||
// Check if 3D Secure authentication is required
|
|
||||||
if (result.requires_action && result.client_secret) {
|
|
||||||
setAuthenticating(true);
|
|
||||||
setLoading(false);
|
|
||||||
setError(null);
|
|
||||||
|
|
||||||
console.log('Starting 3DS authentication process', {
|
|
||||||
client_secret: result.client_secret.substring(0, 20) + '...', // Log partial secret for security
|
|
||||||
payment_method_id: paymentMethod.id,
|
|
||||||
timestamp: new Date().toISOString()
|
|
||||||
});
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Handle 3D Secure authentication
|
|
||||||
const { error: confirmError, setupIntent } = await stripe.confirmCardSetup(result.client_secret);
|
|
||||||
|
|
||||||
console.log('3DS authentication completed', {
|
|
||||||
setup_intent_status: setupIntent?.status,
|
|
||||||
error: confirmError ? confirmError.message : 'none',
|
|
||||||
timestamp: new Date().toISOString()
|
|
||||||
});
|
|
||||||
|
|
||||||
if (confirmError) {
|
|
||||||
// Handle specific 3D Secure error types
|
|
||||||
if (confirmError.type === 'card_error') {
|
|
||||||
setError(confirmError.message || 'Card authentication failed');
|
|
||||||
} else if (confirmError.type === 'validation_error') {
|
|
||||||
setError('Invalid authentication request');
|
|
||||||
} else if (confirmError.type === 'api_error') {
|
|
||||||
setError('Authentication service unavailable');
|
|
||||||
} else {
|
|
||||||
setError(confirmError.message || '3D Secure authentication failed');
|
|
||||||
}
|
|
||||||
setAuthenticating(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check setup intent status after authentication
|
|
||||||
if (setupIntent && setupIntent.status === 'succeeded') {
|
|
||||||
setSuccess(true);
|
|
||||||
showToast.success('Payment method updated and authenticated successfully');
|
|
||||||
|
|
||||||
console.log('3DS authentication successful', {
|
|
||||||
payment_method_id: paymentMethod.id,
|
|
||||||
setup_intent_id: setupIntent.id,
|
|
||||||
setup_intent_status: setupIntent.status,
|
|
||||||
timestamp: new Date().toISOString()
|
|
||||||
});
|
|
||||||
|
|
||||||
// Notify parent component about the update
|
|
||||||
onPaymentMethodUpdated({
|
|
||||||
brand: result.brand,
|
|
||||||
last4: result.last4,
|
|
||||||
exp_month: result.exp_month,
|
|
||||||
exp_year: result.exp_year,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Close modal after a brief delay to show success message
|
|
||||||
setTimeout(() => {
|
|
||||||
onClose();
|
|
||||||
}, 2000);
|
|
||||||
} else {
|
|
||||||
console.log('3DS authentication completed with non-success status', {
|
|
||||||
setup_intent_status: setupIntent?.status,
|
|
||||||
payment_method_id: paymentMethod.id,
|
|
||||||
timestamp: new Date().toISOString()
|
|
||||||
});
|
|
||||||
|
|
||||||
setError('3D Secure authentication completed but payment method not confirmed');
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
|
|
||||||
} catch (authError) {
|
|
||||||
console.error('Error during 3D Secure authentication:', authError);
|
|
||||||
|
|
||||||
// Enhanced error handling for 3DS failures
|
|
||||||
if (authError instanceof Error) {
|
|
||||||
if (authError.message.includes('canceled') || authError.message.includes('user closed')) {
|
|
||||||
setError('3D Secure authentication was canceled. You can try again with the same card.');
|
|
||||||
} else if (authError.message.includes('failed') || authError.message.includes('declined')) {
|
|
||||||
setError('3D Secure authentication failed. Please try again or use a different card.');
|
|
||||||
} else if (authError.message.includes('timeout') || authError.message.includes('network')) {
|
|
||||||
setError('Network error during 3D Secure authentication. Please check your connection and try again.');
|
|
||||||
} else {
|
|
||||||
setError(`3D Secure authentication error: ${authError.message}`);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
setError('3D Secure authentication error. Please try again.');
|
|
||||||
}
|
|
||||||
|
|
||||||
setAuthenticating(false);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// No authentication required
|
|
||||||
setSuccess(true);
|
|
||||||
showToast.success(result.message);
|
|
||||||
|
|
||||||
// Notify parent component about the update
|
|
||||||
onPaymentMethodUpdated({
|
|
||||||
brand: result.brand,
|
|
||||||
last4: result.last4,
|
|
||||||
exp_month: result.exp_month,
|
|
||||||
exp_year: result.exp_year,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Close modal after a brief delay to show success message
|
|
||||||
setTimeout(() => {
|
|
||||||
onClose();
|
|
||||||
}, 2000);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Handle different payment intent statuses
|
|
||||||
if (result.payment_intent_status === 'requires_payment_method') {
|
|
||||||
setError('Your card was declined. Please try a different payment method.');
|
|
||||||
} else if (result.payment_intent_status === 'requires_action') {
|
|
||||||
setError('Authentication required but client secret missing. Please try again.');
|
|
||||||
} else if (result.payment_intent_status === 'processing') {
|
|
||||||
setError('Payment is processing. Please wait a few minutes and try again.');
|
|
||||||
} else if (result.payment_intent_status === 'canceled') {
|
|
||||||
setError('Payment was canceled. Please try again.');
|
|
||||||
} else {
|
|
||||||
setError(result.message || 'Failed to update payment method');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Error updating payment method:', err);
|
|
||||||
setError(err instanceof Error ? err.message : 'An unexpected error occurred');
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleClose = () => {
|
const handleClose = () => {
|
||||||
setError(null);
|
setError(null);
|
||||||
setSuccess(false);
|
setSuccess(false);
|
||||||
setPaymentMethodId('');
|
setStripeLoaded(false);
|
||||||
|
setStripeError(null);
|
||||||
onClose();
|
onClose();
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -306,73 +375,66 @@ export const PaymentMethodUpdateModal: React.FC<PaymentMethodUpdateModalProps> =
|
|||||||
isOpen={isOpen}
|
isOpen={isOpen}
|
||||||
onClose={handleClose}
|
onClose={handleClose}
|
||||||
title="Actualizar Método de Pago"
|
title="Actualizar Método de Pago"
|
||||||
message={null}
|
|
||||||
type="custom"
|
type="custom"
|
||||||
size="lg"
|
size="md"
|
||||||
>
|
message={
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Current Payment Method Info */}
|
{/* Current Payment Method Info */}
|
||||||
{currentPaymentMethod && (
|
{currentPaymentMethod && (
|
||||||
<div className="p-4 bg-[var(--bg-secondary)] rounded-lg border border-[var(--border-secondary)]">
|
<div className="p-4 bg-[var(--bg-secondary)] rounded-lg border border-[var(--border-secondary)]">
|
||||||
<h4 className="font-medium text-[var(--text-primary)] mb-2">Método de Pago Actual</h4>
|
<h4 className="font-medium text-[var(--text-primary)] mb-2">Método de Pago Actual</h4>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<CreditCard className="w-6 h-6 text-blue-500" />
|
<CreditCard className="w-6 h-6 text-blue-500" />
|
||||||
<div>
|
<div>
|
||||||
<p className="text-[var(--text-primary)]">
|
<p className="text-[var(--text-primary)]">
|
||||||
{currentPaymentMethod.brand} terminando en {currentPaymentMethod.last4}
|
{currentPaymentMethod.brand} terminando en {currentPaymentMethod.last4}
|
||||||
</p>
|
|
||||||
{currentPaymentMethod.exp_month && currentPaymentMethod.exp_year && (
|
|
||||||
<p className="text-sm text-[var(--text-secondary)]">
|
|
||||||
Expira: {currentPaymentMethod.exp_month}/{currentPaymentMethod.exp_year}
|
|
||||||
</p>
|
</p>
|
||||||
)}
|
{currentPaymentMethod.exp_month && currentPaymentMethod.exp_year && (
|
||||||
|
<p className="text-sm text-[var(--text-secondary)]">
|
||||||
|
Expira: {currentPaymentMethod.exp_month}/{currentPaymentMethod.exp_year}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)}
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Payment Form */}
|
{/* Stripe Elements Provider with Card Form */}
|
||||||
<form onSubmit={handleSubmit} className="space-y-4">
|
{!stripeLoaded && !stripeError && (
|
||||||
<div>
|
<div className="p-4 bg-blue-500/5 border border-blue-500/20 rounded-lg text-center">
|
||||||
<label className="block text-sm font-medium text-[var(--text-primary)] mb-2">
|
<Loader2 className="w-6 h-6 animate-spin mx-auto mb-2 text-blue-500" />
|
||||||
Nueva Tarjeta
|
<span className="text-[var(--text-secondary)]">Cargando procesador de pagos seguro...</span>
|
||||||
</label>
|
|
||||||
<div id="card-element" className="p-3 border border-[var(--border-primary)] rounded-lg bg-[var(--bg-primary)]">
|
|
||||||
{/* Stripe card element will be mounted here */}
|
|
||||||
{!cardElement && !error && (
|
|
||||||
<div className="flex items-center gap-2 text-[var(--text-secondary)]">
|
|
||||||
<Loader2 className="w-4 h-4 animate-spin" />
|
|
||||||
<span>Cargando procesador de pagos...</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{authenticating && (
|
|
||||||
<div className="p-3 bg-blue-500/10 border border-blue-500/20 rounded-lg flex items-center gap-2 text-blue-500">
|
|
||||||
<CreditCard className="w-4 h-4" />
|
|
||||||
<span className="text-sm">Completing secure authentication with your bank...</span>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{error && (
|
{stripeError && (
|
||||||
<div className="p-3 bg-red-500/10 border border-red-500/20 rounded-lg flex items-center gap-2 text-red-500">
|
<div className="p-4 bg-red-500/10 border border-red-500/20 rounded-lg text-center text-red-500">
|
||||||
<AlertCircle className="w-4 h-4" />
|
<AlertCircle className="w-6 h-6 mx-auto mb-2" />
|
||||||
<span className="text-sm">{error}</span>
|
<span className="text-sm">Error loading payment processor. Please refresh the page.</span>
|
||||||
{error.includes('3D Secure') && (
|
<Button
|
||||||
<Button
|
type="button"
|
||||||
type="button"
|
variant="text"
|
||||||
variant="text"
|
size="sm"
|
||||||
size="sm"
|
onClick={() => window.location.reload()}
|
||||||
onClick={() => setError(null)}
|
className="mt-2 text-red-600 hover:text-red-800"
|
||||||
className="ml-auto text-red-600 hover:text-red-800"
|
>
|
||||||
>
|
Refresh Page
|
||||||
Try Again
|
</Button>
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{stripeLoaded && stripePromise && (
|
||||||
|
<Elements stripe={stripePromise}>
|
||||||
|
<StripeCardForm
|
||||||
|
tenantId={tenantId}
|
||||||
|
onSuccess={handleSuccess}
|
||||||
|
onError={handleError}
|
||||||
|
onLoading={handleLoading}
|
||||||
|
/>
|
||||||
|
</Elements>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Success Message */}
|
||||||
{success && (
|
{success && (
|
||||||
<div className="p-3 bg-green-500/10 border border-green-500/20 rounded-lg flex items-center gap-2 text-green-500">
|
<div className="p-3 bg-green-500/10 border border-green-500/20 rounded-lg flex items-center gap-2 text-green-500">
|
||||||
<CheckCircle className="w-4 h-4" />
|
<CheckCircle className="w-4 h-4" />
|
||||||
@@ -380,35 +442,23 @@ export const PaymentMethodUpdateModal: React.FC<PaymentMethodUpdateModalProps> =
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="flex gap-3 justify-end">
|
{/* Error Message (from modal level) */}
|
||||||
<Button
|
{error && (
|
||||||
type="button"
|
<div className="p-3 bg-red-500/10 border border-red-500/20 rounded-lg flex items-center gap-2 text-red-500">
|
||||||
variant="outline"
|
<AlertCircle className="w-4 h-4" />
|
||||||
onClick={handleClose}
|
<span className="text-sm">{error}</span>
|
||||||
disabled={loading || authenticating}
|
</div>
|
||||||
>
|
)}
|
||||||
Cancelar
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
type="submit"
|
|
||||||
variant="primary"
|
|
||||||
disabled={loading || authenticating || !cardElement}
|
|
||||||
className="flex items-center gap-2"
|
|
||||||
>
|
|
||||||
{(loading || authenticating) && <Loader2 className="w-4 h-4 animate-spin" />}
|
|
||||||
{authenticating ? 'Autenticando...' : loading ? 'Procesando...' : 'Actualizar Método de Pago'}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
{/* Security Info */}
|
{/* Security Info */}
|
||||||
<div className="p-3 bg-blue-500/5 border border-blue-500/20 rounded-lg text-sm text-[var(--text-secondary)]">
|
<div className="p-3 bg-blue-500/5 border border-blue-500/20 rounded-lg text-sm text-[var(--text-secondary)]">
|
||||||
<p className="flex items-center gap-2">
|
<p className="flex items-center gap-2">
|
||||||
<CreditCard className="w-4 h-4 text-blue-500" />
|
<CreditCard className="w-4 h-4 text-blue-500" />
|
||||||
<span>Tus datos de pago están protegidos y se procesan de forma segura.</span>
|
<span>Tus datos de pago están protegidos y se procesan de forma segura.</span>
|
||||||
</p>
|
</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
}
|
||||||
</DialogModal>
|
/>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -64,6 +64,7 @@ interface CommunicationPreferencesProps {
|
|||||||
onSave: (preferences: NotificationPreferences) => Promise<void>;
|
onSave: (preferences: NotificationPreferences) => Promise<void>;
|
||||||
onReset: () => void;
|
onReset: () => void;
|
||||||
hasChanges: boolean;
|
hasChanges: boolean;
|
||||||
|
onPreferencesChange?: (preferences: NotificationPreferences) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const CommunicationPreferences: React.FC<CommunicationPreferencesProps> = ({
|
const CommunicationPreferences: React.FC<CommunicationPreferencesProps> = ({
|
||||||
@@ -144,10 +145,16 @@ const CommunicationPreferences: React.FC<CommunicationPreferencesProps> = ({
|
|||||||
];
|
];
|
||||||
|
|
||||||
const handlePreferenceChange = (key: keyof NotificationPreferences, value: any) => {
|
const handlePreferenceChange = (key: keyof NotificationPreferences, value: any) => {
|
||||||
setPreferences(prev => ({
|
const newPreferences = {
|
||||||
...prev,
|
...preferences,
|
||||||
[key]: value
|
[key]: value
|
||||||
}));
|
};
|
||||||
|
setPreferences(newPreferences);
|
||||||
|
|
||||||
|
// Notify parent component about changes
|
||||||
|
if (onPreferencesChange) {
|
||||||
|
onPreferencesChange(newPreferences);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleContactChange = (field: 'email' | 'phone', value: string) => {
|
const handleContactChange = (field: 'email' | 'phone', value: string) => {
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -335,6 +335,9 @@ const SubscriptionPageRedesign: React.FC = () => {
|
|||||||
exp_year?: number;
|
exp_year?: number;
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
|
|
||||||
|
// Billing cycle state (monthly/yearly)
|
||||||
|
const [billingCycle, setBillingCycle] = useState<'monthly' | 'yearly'>('monthly');
|
||||||
|
|
||||||
// Section visibility states
|
// Section visibility states
|
||||||
const [showUsage, setShowUsage] = useState(false);
|
const [showUsage, setShowUsage] = useState(false);
|
||||||
const [showBilling, setShowBilling] = useState(false);
|
const [showBilling, setShowBilling] = useState(false);
|
||||||
@@ -439,6 +442,10 @@ const SubscriptionPageRedesign: React.FC = () => {
|
|||||||
|
|
||||||
setUsageSummary(usage);
|
setUsageSummary(usage);
|
||||||
setAvailablePlans(plans);
|
setAvailablePlans(plans);
|
||||||
|
// Initialize billing cycle from current subscription
|
||||||
|
if (usage.billing_cycle) {
|
||||||
|
setBillingCycle(usage.billing_cycle);
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error loading subscription data:', error);
|
console.error('Error loading subscription data:', error);
|
||||||
showToast.error(
|
showToast.error(
|
||||||
@@ -470,6 +477,7 @@ const SubscriptionPageRedesign: React.FC = () => {
|
|||||||
try {
|
try {
|
||||||
setUpgrading(true);
|
setUpgrading(true);
|
||||||
|
|
||||||
|
// Step 1: Validate the upgrade
|
||||||
const validation = await subscriptionService.validatePlanUpgrade(
|
const validation = await subscriptionService.validatePlanUpgrade(
|
||||||
tenantId,
|
tenantId,
|
||||||
selectedPlan
|
selectedPlan
|
||||||
@@ -480,21 +488,46 @@ const SubscriptionPageRedesign: React.FC = () => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await subscriptionService.upgradePlan(tenantId, selectedPlan);
|
// Step 2: Execute the upgrade (uses current billing cycle)
|
||||||
|
const result = await subscriptionService.upgradePlan(
|
||||||
|
tenantId,
|
||||||
|
selectedPlan,
|
||||||
|
billingCycle // Pass the currently selected billing cycle
|
||||||
|
);
|
||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
showToast.success(result.message);
|
// Show appropriate success message based on trial status
|
||||||
|
if (result.is_trialing && result.trial_preserved) {
|
||||||
|
showToast.success(
|
||||||
|
`¡Plan actualizado a ${result.new_plan}! Tu período de prueba continúa hasta ${
|
||||||
|
result.trial_ends_at ? new Date(result.trial_ends_at).toLocaleDateString('es-ES') : 'su fecha original'
|
||||||
|
}. Al finalizar, se aplicará el precio del nuevo plan.`
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
showToast.success(result.message || `¡Plan actualizado exitosamente a ${result.new_plan}!`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 3: Invalidate caches to refresh UI
|
||||||
subscriptionService.invalidateCache();
|
subscriptionService.invalidateCache();
|
||||||
|
|
||||||
|
// Step 4: Refresh subscription data from server
|
||||||
if (result.requires_token_refresh) {
|
if (result.requires_token_refresh) {
|
||||||
|
// Force a fresh fetch of subscription data
|
||||||
await subscriptionService.getUsageSummary(tenantId).catch(() => {});
|
await subscriptionService.getUsageSummary(tenantId).catch(() => {});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Step 5: Invalidate React Query caches
|
||||||
await queryClient.invalidateQueries({ queryKey: ['subscription-usage'] });
|
await queryClient.invalidateQueries({ queryKey: ['subscription-usage'] });
|
||||||
await queryClient.invalidateQueries({ queryKey: ['tenant'] });
|
await queryClient.invalidateQueries({ queryKey: ['tenant'] });
|
||||||
|
await queryClient.invalidateQueries({ queryKey: ['subscription'] });
|
||||||
|
|
||||||
|
// Step 6: Notify other components about the change
|
||||||
notifySubscriptionChanged();
|
notifySubscriptionChanged();
|
||||||
|
|
||||||
|
// Step 7: Reload subscription data for this page
|
||||||
await loadSubscriptionData();
|
await loadSubscriptionData();
|
||||||
|
|
||||||
|
// Step 8: Close dialog and reset state
|
||||||
setUpgradeDialogOpen(false);
|
setUpgradeDialogOpen(false);
|
||||||
setSelectedPlan('');
|
setSelectedPlan('');
|
||||||
} else {
|
} else {
|
||||||
@@ -502,7 +535,8 @@ const SubscriptionPageRedesign: React.FC = () => {
|
|||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error upgrading plan:', error);
|
console.error('Error upgrading plan:', error);
|
||||||
showToast.error('Error al procesar el cambio de plan');
|
const errorMessage = error instanceof Error ? error.message : 'Error al procesar el cambio de plan';
|
||||||
|
showToast.error(errorMessage);
|
||||||
} finally {
|
} finally {
|
||||||
setUpgrading(false);
|
setUpgrading(false);
|
||||||
}
|
}
|
||||||
@@ -647,9 +681,9 @@ const SubscriptionPageRedesign: React.FC = () => {
|
|||||||
// Determine if this is a pilot subscription based on characteristics
|
// Determine if this is a pilot subscription based on characteristics
|
||||||
// Pilot subscriptions have extended trial periods (typically 90 days from PILOT2025 coupon)
|
// Pilot subscriptions have extended trial periods (typically 90 days from PILOT2025 coupon)
|
||||||
// compared to regular trials (typically 14 days), so we check for longer trial periods
|
// compared to regular trials (typically 14 days), so we check for longer trial periods
|
||||||
const isPilotSubscription = usageSummary.status === 'trialing' &&
|
const isPilotSubscription = (usageSummary.status === 'trialing' ||
|
||||||
|
(usageSummary.trial_ends_at && new Date(usageSummary.trial_ends_at) > new Date())) &&
|
||||||
usageSummary.trial_ends_at &&
|
usageSummary.trial_ends_at &&
|
||||||
new Date(usageSummary.trial_ends_at) > new Date() &&
|
|
||||||
// Check if trial period is longer than typical trial (e.g., > 60 days indicates pilot)
|
// Check if trial period is longer than typical trial (e.g., > 60 days indicates pilot)
|
||||||
(new Date(usageSummary.trial_ends_at).getTime() - new Date().getTime()) > (60 * 24 * 60 * 60 * 1000); // 60+ days
|
(new Date(usageSummary.trial_ends_at).getTime() - new Date().getTime()) > (60 * 24 * 60 * 60 * 1000); // 60+ days
|
||||||
|
|
||||||
@@ -726,6 +760,10 @@ const SubscriptionPageRedesign: React.FC = () => {
|
|||||||
onToggle={() => setShowUsage(!showUsage)}
|
onToggle={() => setShowUsage(!showUsage)}
|
||||||
showAlert={hasHighUsageMetrics}
|
showAlert={hasHighUsageMetrics}
|
||||||
>
|
>
|
||||||
|
|
||||||
|
{/* Spacing between content blocks */}
|
||||||
|
<div className="h-6"></div>
|
||||||
|
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Team & Organization */}
|
{/* Team & Organization */}
|
||||||
<div>
|
<div>
|
||||||
@@ -886,6 +924,10 @@ const SubscriptionPageRedesign: React.FC = () => {
|
|||||||
isOpen={showBilling}
|
isOpen={showBilling}
|
||||||
onToggle={() => setShowBilling(!showBilling)}
|
onToggle={() => setShowBilling(!showBilling)}
|
||||||
>
|
>
|
||||||
|
|
||||||
|
{/* Spacing between content blocks */}
|
||||||
|
<div className="h-6"></div>
|
||||||
|
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Payment Method */}
|
{/* Payment Method */}
|
||||||
<div className="p-4 bg-[var(--bg-secondary)] rounded-lg border border-[var(--border-secondary)]">
|
<div className="p-4 bg-[var(--bg-secondary)] rounded-lg border border-[var(--border-secondary)]">
|
||||||
@@ -931,8 +973,21 @@ const SubscriptionPageRedesign: React.FC = () => {
|
|||||||
<div className="p-4 bg-[var(--bg-secondary)] rounded-full">
|
<div className="p-4 bg-[var(--bg-secondary)] rounded-full">
|
||||||
<Download className="w-8 h-8 text-[var(--text-tertiary)]" />
|
<Download className="w-8 h-8 text-[var(--text-tertiary)]" />
|
||||||
</div>
|
</div>
|
||||||
<p className="text-[var(--text-secondary)]">No hay facturas disponibles</p>
|
{(usageSummary?.status === 'trialing' ||
|
||||||
<p className="text-sm text-[var(--text-tertiary)]">Las facturas aparecerán aquí una vez realizados los pagos</p>
|
(usageSummary?.trial_ends_at && new Date(usageSummary.trial_ends_at) > new Date())) ? (
|
||||||
|
<>
|
||||||
|
<p className="text-[var(--text-secondary)]">Periodo de prueba activo</p>
|
||||||
|
<p className="text-sm text-[var(--text-tertiary)]">
|
||||||
|
No hay facturas durante el periodo de prueba. Las facturas aparecerán aquí
|
||||||
|
una vez finalice el periodo de prueba el {usageSummary?.trial_ends_at ? new Date(usageSummary.trial_ends_at).toLocaleDateString('es-ES') : 'próximamente'}.
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<p className="text-[var(--text-secondary)]">No hay facturas disponibles</p>
|
||||||
|
<p className="text-sm text-[var(--text-tertiary)]">Las facturas aparecerán aquí una vez realizados los pagos</p>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
@@ -951,21 +1006,32 @@ const SubscriptionPageRedesign: React.FC = () => {
|
|||||||
{Array.isArray(invoices) && invoices.slice(0, 5).map((invoice) => (
|
{Array.isArray(invoices) && invoices.slice(0, 5).map((invoice) => (
|
||||||
<tr key={invoice.id} className="border-b border-[var(--border-color)] hover:bg-[var(--bg-secondary)] transition-colors">
|
<tr key={invoice.id} className="border-b border-[var(--border-color)] hover:bg-[var(--bg-secondary)] transition-colors">
|
||||||
<td className="py-3 px-4 text-[var(--text-primary)] font-medium">
|
<td className="py-3 px-4 text-[var(--text-primary)] font-medium">
|
||||||
{new Date(invoice.date).toLocaleDateString('es-ES', {
|
{invoice.created ? new Date(invoice.created * 1000).toLocaleDateString('es-ES', {
|
||||||
day: '2-digit',
|
day: '2-digit',
|
||||||
month: 'short',
|
month: 'short',
|
||||||
year: 'numeric'
|
year: 'numeric'
|
||||||
})}
|
}) : 'N/A'}
|
||||||
</td>
|
</td>
|
||||||
<td className="py-3 px-4 text-[var(--text-primary)]">
|
<td className="py-3 px-4 text-[var(--text-primary)]">
|
||||||
{invoice.description || 'Suscripción'}
|
{invoice.trial ? (
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<span className="text-green-500">●</span>
|
||||||
|
Periodo de prueba
|
||||||
|
</span>
|
||||||
|
) : (invoice.description || 'Suscripción')}
|
||||||
</td>
|
</td>
|
||||||
<td className="py-3 px-4 text-[var(--text-primary)] font-semibold text-right">
|
<td className="py-3 px-4 text-[var(--text-primary)] font-semibold text-right">
|
||||||
{subscriptionService.formatPrice(invoice.amount)}
|
{invoice.amount_due !== undefined ? (
|
||||||
|
invoice.trial ? (
|
||||||
|
<span className="text-green-500">0,00 €</span>
|
||||||
|
) : (
|
||||||
|
subscriptionService.formatPrice(invoice.amount_due)
|
||||||
|
)
|
||||||
|
) : 'N/A'}
|
||||||
</td>
|
</td>
|
||||||
<td className="py-3 px-4 text-center">
|
<td className="py-3 px-4 text-center">
|
||||||
<Badge variant={invoice.status === 'paid' ? 'success' : invoice.status === 'open' ? 'warning' : 'default'}>
|
<Badge variant={invoice.trial ? 'success' : invoice.status === 'paid' ? 'success' : invoice.status === 'open' ? 'warning' : 'default'}>
|
||||||
{invoice.status === 'paid' ? 'Pagada' : invoice.status === 'open' ? 'Pendiente' : invoice.status}
|
{invoice.trial ? 'Prueba' : invoice.status === 'paid' ? 'Pagada' : invoice.status === 'open' ? 'Pendiente' : invoice.status}
|
||||||
</Badge>
|
</Badge>
|
||||||
</td>
|
</td>
|
||||||
<td className="py-3 px-4 text-center">
|
<td className="py-3 px-4 text-center">
|
||||||
@@ -977,7 +1043,7 @@ const SubscriptionPageRedesign: React.FC = () => {
|
|||||||
className="flex items-center gap-2 mx-auto"
|
className="flex items-center gap-2 mx-auto"
|
||||||
>
|
>
|
||||||
<Download className="w-4 h-4" />
|
<Download className="w-4 h-4" />
|
||||||
{invoice.invoice_pdf ? 'PDF' : 'Ver'}
|
{invoice.invoice_pdf ? 'PDF' : invoice.hosted_invoice_url ? 'Ver' : 'Descargar'}
|
||||||
</Button>
|
</Button>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -995,23 +1061,7 @@ const SubscriptionPageRedesign: React.FC = () => {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Support Contact */}
|
|
||||||
<div className="p-4 bg-blue-500/5 border border-blue-500/20 rounded-lg">
|
|
||||||
<div className="flex items-start gap-3">
|
|
||||||
<Activity className="w-5 h-5 text-blue-500 flex-shrink-0 mt-0.5" />
|
|
||||||
<div>
|
|
||||||
<p className="text-sm text-[var(--text-primary)] font-medium mb-1">
|
|
||||||
¿Preguntas sobre tu factura?
|
|
||||||
</p>
|
|
||||||
<p className="text-sm text-[var(--text-secondary)]">
|
|
||||||
Contacta a nuestro equipo de soporte en{' '}
|
|
||||||
<a href="mailto:support@bakery-ia.com" className="text-blue-500 hover:text-white hover:bg-blue-500 px-2 py-0.5 rounded transition-all duration-200 no-underline">
|
|
||||||
support@bakery-ia.com
|
|
||||||
</a>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</CollapsibleSection>
|
</CollapsibleSection>
|
||||||
|
|
||||||
@@ -1022,6 +1072,10 @@ const SubscriptionPageRedesign: React.FC = () => {
|
|||||||
isOpen={showPlans}
|
isOpen={showPlans}
|
||||||
onToggle={() => setShowPlans(!showPlans)}
|
onToggle={() => setShowPlans(!showPlans)}
|
||||||
>
|
>
|
||||||
|
|
||||||
|
{/* Spacing between content blocks */}
|
||||||
|
<div className="h-6"></div>
|
||||||
|
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Available Plans */}
|
{/* Available Plans */}
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -1,78 +0,0 @@
|
|||||||
"""add_gdpr_consent_tables
|
|
||||||
|
|
||||||
Revision ID: 510cf1184e0b
|
|
||||||
Revises: 13327ad46a4d
|
|
||||||
Create Date: 2025-10-15 21:55:40.584671+02:00
|
|
||||||
|
|
||||||
"""
|
|
||||||
from typing import Sequence, Union
|
|
||||||
|
|
||||||
from alembic import op
|
|
||||||
import sqlalchemy as sa
|
|
||||||
from sqlalchemy.dialects import postgresql
|
|
||||||
|
|
||||||
# revision identifiers, used by Alembic.
|
|
||||||
revision: str = '510cf1184e0b'
|
|
||||||
down_revision: Union[str, None] = '13327ad46a4d'
|
|
||||||
branch_labels: Union[str, Sequence[str], None] = None
|
|
||||||
depends_on: Union[str, Sequence[str], None] = None
|
|
||||||
|
|
||||||
|
|
||||||
def upgrade() -> None:
|
|
||||||
# ### commands auto generated by Alembic - please adjust! ###
|
|
||||||
op.create_table('user_consents',
|
|
||||||
sa.Column('id', sa.UUID(), nullable=False),
|
|
||||||
sa.Column('user_id', sa.UUID(), nullable=False),
|
|
||||||
sa.Column('terms_accepted', sa.Boolean(), nullable=False),
|
|
||||||
sa.Column('privacy_accepted', sa.Boolean(), nullable=False),
|
|
||||||
sa.Column('marketing_consent', sa.Boolean(), nullable=False),
|
|
||||||
sa.Column('analytics_consent', sa.Boolean(), nullable=False),
|
|
||||||
sa.Column('consent_version', sa.String(length=20), nullable=False),
|
|
||||||
sa.Column('consent_method', sa.String(length=50), nullable=False),
|
|
||||||
sa.Column('ip_address', sa.String(length=45), nullable=True),
|
|
||||||
sa.Column('user_agent', sa.Text(), nullable=True),
|
|
||||||
sa.Column('terms_text_hash', sa.String(length=64), nullable=True),
|
|
||||||
sa.Column('privacy_text_hash', sa.String(length=64), nullable=True),
|
|
||||||
sa.Column('consented_at', sa.DateTime(timezone=True), nullable=False),
|
|
||||||
sa.Column('withdrawn_at', sa.DateTime(timezone=True), nullable=True),
|
|
||||||
sa.Column('extra_data', postgresql.JSON(astext_type=sa.Text()), nullable=True),
|
|
||||||
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'),
|
|
||||||
sa.PrimaryKeyConstraint('id')
|
|
||||||
)
|
|
||||||
op.create_index('idx_user_consent_consented_at', 'user_consents', ['consented_at'], unique=False)
|
|
||||||
op.create_index('idx_user_consent_user_id', 'user_consents', ['user_id'], unique=False)
|
|
||||||
op.create_index(op.f('ix_user_consents_user_id'), 'user_consents', ['user_id'], unique=False)
|
|
||||||
op.create_table('consent_history',
|
|
||||||
sa.Column('id', sa.UUID(), nullable=False),
|
|
||||||
sa.Column('user_id', sa.UUID(), nullable=False),
|
|
||||||
sa.Column('consent_id', sa.UUID(), nullable=True),
|
|
||||||
sa.Column('action', sa.String(length=50), nullable=False),
|
|
||||||
sa.Column('consent_snapshot', postgresql.JSON(astext_type=sa.Text()), nullable=False),
|
|
||||||
sa.Column('ip_address', sa.String(length=45), nullable=True),
|
|
||||||
sa.Column('user_agent', sa.Text(), nullable=True),
|
|
||||||
sa.Column('consent_method', sa.String(length=50), nullable=True),
|
|
||||||
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
|
|
||||||
sa.ForeignKeyConstraint(['consent_id'], ['user_consents.id'], ondelete='SET NULL'),
|
|
||||||
sa.PrimaryKeyConstraint('id')
|
|
||||||
)
|
|
||||||
op.create_index('idx_consent_history_action', 'consent_history', ['action'], unique=False)
|
|
||||||
op.create_index('idx_consent_history_created_at', 'consent_history', ['created_at'], unique=False)
|
|
||||||
op.create_index('idx_consent_history_user_id', 'consent_history', ['user_id'], unique=False)
|
|
||||||
op.create_index(op.f('ix_consent_history_created_at'), 'consent_history', ['created_at'], unique=False)
|
|
||||||
op.create_index(op.f('ix_consent_history_user_id'), 'consent_history', ['user_id'], unique=False)
|
|
||||||
# ### end Alembic commands ###
|
|
||||||
|
|
||||||
|
|
||||||
def downgrade() -> None:
|
|
||||||
# ### commands auto generated by Alembic - please adjust! ###
|
|
||||||
op.drop_index(op.f('ix_consent_history_user_id'), table_name='consent_history')
|
|
||||||
op.drop_index(op.f('ix_consent_history_created_at'), table_name='consent_history')
|
|
||||||
op.drop_index('idx_consent_history_user_id', table_name='consent_history')
|
|
||||||
op.drop_index('idx_consent_history_created_at', table_name='consent_history')
|
|
||||||
op.drop_index('idx_consent_history_action', table_name='consent_history')
|
|
||||||
op.drop_table('consent_history')
|
|
||||||
op.drop_index(op.f('ix_user_consents_user_id'), table_name='user_consents')
|
|
||||||
op.drop_index('idx_user_consent_user_id', table_name='user_consents')
|
|
||||||
op.drop_index('idx_user_consent_consented_at', table_name='user_consents')
|
|
||||||
op.drop_table('user_consents')
|
|
||||||
# ### end Alembic commands ###
|
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
"""add_payment_columns_to_users
|
|
||||||
|
|
||||||
Revision ID: 20260113_add_payment_columns
|
|
||||||
Revises: 510cf1184e0b
|
|
||||||
Create Date: 2026-01-13 13:30:00.000000+00:00
|
|
||||||
|
|
||||||
Add payment_customer_id and default_payment_method_id columns to users table
|
|
||||||
to support payment integration.
|
|
||||||
"""
|
|
||||||
from typing import Sequence, Union
|
|
||||||
|
|
||||||
from alembic import op
|
|
||||||
import sqlalchemy as sa
|
|
||||||
|
|
||||||
# revision identifiers, used by Alembic.
|
|
||||||
revision: str = '20260113_add_payment_columns'
|
|
||||||
down_revision: Union[str, None] = '510cf1184e0b'
|
|
||||||
branch_labels: Union[str, Sequence[str], None] = None
|
|
||||||
depends_on: Union[str, Sequence[str], None] = None
|
|
||||||
|
|
||||||
|
|
||||||
def upgrade() -> None:
|
|
||||||
# Add payment_customer_id column
|
|
||||||
op.add_column('users',
|
|
||||||
sa.Column('payment_customer_id', sa.String(length=255), nullable=True))
|
|
||||||
|
|
||||||
# Add default_payment_method_id column
|
|
||||||
op.add_column('users',
|
|
||||||
sa.Column('default_payment_method_id', sa.String(length=255), nullable=True))
|
|
||||||
|
|
||||||
# Create index for payment_customer_id
|
|
||||||
op.create_index(op.f('ix_users_payment_customer_id'), 'users', ['payment_customer_id'], unique=False)
|
|
||||||
|
|
||||||
|
|
||||||
def downgrade() -> None:
|
|
||||||
# Drop index first
|
|
||||||
op.drop_index(op.f('ix_users_payment_customer_id'), table_name='users')
|
|
||||||
|
|
||||||
# Drop columns
|
|
||||||
op.drop_column('users', 'default_payment_method_id')
|
|
||||||
op.drop_column('users', 'payment_customer_id')
|
|
||||||
@@ -1,50 +0,0 @@
|
|||||||
"""Add password reset token table
|
|
||||||
|
|
||||||
Revision ID: 20260116_add_password_reset_token_table
|
|
||||||
Revises: 20260113_add_payment_columns_to_users.py
|
|
||||||
Create Date: 2026-01-16 10:00:00.000000
|
|
||||||
|
|
||||||
"""
|
|
||||||
from alembic import op
|
|
||||||
import sqlalchemy as sa
|
|
||||||
from sqlalchemy.dialects import postgresql
|
|
||||||
|
|
||||||
# revision identifiers
|
|
||||||
revision = '20260116_add_password_reset_token_table'
|
|
||||||
down_revision = '20260113_add_payment_columns_to_users.py'
|
|
||||||
branch_labels = None
|
|
||||||
depends_on = None
|
|
||||||
|
|
||||||
|
|
||||||
def upgrade() -> None:
|
|
||||||
# Create password_reset_tokens table
|
|
||||||
op.create_table(
|
|
||||||
'password_reset_tokens',
|
|
||||||
sa.Column('id', postgresql.UUID(as_uuid=True), nullable=False),
|
|
||||||
sa.Column('user_id', postgresql.UUID(as_uuid=True), nullable=False),
|
|
||||||
sa.Column('token', sa.String(length=255), nullable=False),
|
|
||||||
sa.Column('expires_at', sa.DateTime(timezone=True), nullable=False),
|
|
||||||
sa.Column('is_used', sa.Boolean(), nullable=False, default=False),
|
|
||||||
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False,
|
|
||||||
server_default=sa.text("timezone('utc', CURRENT_TIMESTAMP)")),
|
|
||||||
sa.Column('used_at', sa.DateTime(timezone=True), nullable=True),
|
|
||||||
sa.PrimaryKeyConstraint('id'),
|
|
||||||
sa.UniqueConstraint('token'),
|
|
||||||
)
|
|
||||||
|
|
||||||
# Create indexes
|
|
||||||
op.create_index('ix_password_reset_tokens_user_id', 'password_reset_tokens', ['user_id'])
|
|
||||||
op.create_index('ix_password_reset_tokens_token', 'password_reset_tokens', ['token'])
|
|
||||||
op.create_index('ix_password_reset_tokens_expires_at', 'password_reset_tokens', ['expires_at'])
|
|
||||||
op.create_index('ix_password_reset_tokens_is_used', 'password_reset_tokens', ['is_used'])
|
|
||||||
|
|
||||||
|
|
||||||
def downgrade() -> None:
|
|
||||||
# Drop indexes
|
|
||||||
op.drop_index('ix_password_reset_tokens_is_used', table_name='password_reset_tokens')
|
|
||||||
op.drop_index('ix_password_reset_tokens_expires_at', table_name='password_reset_tokens')
|
|
||||||
op.drop_index('ix_password_reset_tokens_token', table_name='password_reset_tokens')
|
|
||||||
op.drop_index('ix_password_reset_tokens_user_id', table_name='password_reset_tokens')
|
|
||||||
|
|
||||||
# Drop table
|
|
||||||
op.drop_table('password_reset_tokens')
|
|
||||||
@@ -1,8 +1,15 @@
|
|||||||
"""initial_schema_20251015_1229
|
"""Unified initial schema for auth service
|
||||||
|
|
||||||
Revision ID: 13327ad46a4d
|
This migration combines all previous migrations into a single initial schema:
|
||||||
|
- Initial tables (users, refresh_tokens, login_attempts, audit_logs, onboarding)
|
||||||
|
- GDPR consent tables (user_consents, consent_history)
|
||||||
|
- Payment columns added to users table
|
||||||
|
- Password reset tokens table
|
||||||
|
- Tenant ID made nullable in audit logs
|
||||||
|
|
||||||
|
Revision ID: initial_unified
|
||||||
Revises:
|
Revises:
|
||||||
Create Date: 2025-10-15 12:29:13.886996+02:00
|
Create Date: 2026-01-16 14:00:00.000000
|
||||||
|
|
||||||
"""
|
"""
|
||||||
from typing import Sequence, Union
|
from typing import Sequence, Union
|
||||||
@@ -12,17 +19,163 @@ import sqlalchemy as sa
|
|||||||
from sqlalchemy.dialects import postgresql
|
from sqlalchemy.dialects import postgresql
|
||||||
|
|
||||||
# revision identifiers, used by Alembic.
|
# revision identifiers, used by Alembic.
|
||||||
revision: str = '13327ad46a4d'
|
revision: str = 'initial_unified'
|
||||||
down_revision: Union[str, None] = None
|
down_revision: Union[str, None] = None
|
||||||
branch_labels: Union[str, Sequence[str], None] = None
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
depends_on: Union[str, Sequence[str], None] = None
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
def upgrade() -> None:
|
def upgrade() -> None:
|
||||||
# ### commands auto generated by Alembic - please adjust! ###
|
# Create all tables in the correct order (respecting foreign key dependencies)
|
||||||
|
|
||||||
|
# Base tables without dependencies
|
||||||
|
op.create_table('users',
|
||||||
|
sa.Column('id', sa.UUID(), nullable=False),
|
||||||
|
sa.Column('email', sa.String(length=255), nullable=False),
|
||||||
|
sa.Column('hashed_password', sa.String(length=255), nullable=False),
|
||||||
|
sa.Column('full_name', sa.String(length=255), nullable=False),
|
||||||
|
sa.Column('is_active', sa.Boolean(), nullable=True),
|
||||||
|
sa.Column('is_verified', sa.Boolean(), nullable=True),
|
||||||
|
sa.Column('created_at', sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column('last_login', sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column('phone', sa.String(length=20), nullable=True),
|
||||||
|
sa.Column('language', sa.String(length=10), nullable=True),
|
||||||
|
sa.Column('timezone', sa.String(length=50), nullable=True),
|
||||||
|
sa.Column('role', sa.String(length=20), nullable=False),
|
||||||
|
# Payment-related columns
|
||||||
|
sa.Column('payment_customer_id', sa.String(length=255), nullable=True),
|
||||||
|
sa.Column('default_payment_method_id', sa.String(length=255), nullable=True),
|
||||||
|
sa.PrimaryKeyConstraint('id')
|
||||||
|
)
|
||||||
|
op.create_index(op.f('ix_users_email'), 'users', ['email'], unique=True)
|
||||||
|
op.create_index(op.f('ix_users_payment_customer_id'), 'users', ['payment_customer_id'], unique=False)
|
||||||
|
|
||||||
|
op.create_table('login_attempts',
|
||||||
|
sa.Column('id', sa.UUID(), nullable=False),
|
||||||
|
sa.Column('email', sa.String(length=255), nullable=False),
|
||||||
|
sa.Column('ip_address', sa.String(length=45), nullable=False),
|
||||||
|
sa.Column('user_agent', sa.Text(), nullable=True),
|
||||||
|
sa.Column('success', sa.Boolean(), nullable=True),
|
||||||
|
sa.Column('failure_reason', sa.String(length=255), nullable=True),
|
||||||
|
sa.Column('created_at', sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.PrimaryKeyConstraint('id')
|
||||||
|
)
|
||||||
|
op.create_index(op.f('ix_login_attempts_email'), 'login_attempts', ['email'], unique=False)
|
||||||
|
|
||||||
|
# Tables that reference users
|
||||||
|
op.create_table('refresh_tokens',
|
||||||
|
sa.Column('id', sa.UUID(), nullable=False),
|
||||||
|
sa.Column('user_id', sa.UUID(), nullable=False),
|
||||||
|
sa.Column('token', sa.Text(), nullable=False),
|
||||||
|
sa.Column('token_hash', sa.String(length=255), nullable=True),
|
||||||
|
sa.Column('expires_at', sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.Column('is_revoked', sa.Boolean(), nullable=False),
|
||||||
|
sa.Column('created_at', sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column('revoked_at', sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.PrimaryKeyConstraint('id'),
|
||||||
|
sa.UniqueConstraint('token_hash')
|
||||||
|
)
|
||||||
|
op.create_index('ix_refresh_tokens_expires_at', 'refresh_tokens', ['expires_at'], unique=False)
|
||||||
|
op.create_index('ix_refresh_tokens_token_hash', 'refresh_tokens', ['token_hash'], unique=False)
|
||||||
|
op.create_index(op.f('ix_refresh_tokens_user_id'), 'refresh_tokens', ['user_id'], unique=False)
|
||||||
|
op.create_index('ix_refresh_tokens_user_id_active', 'refresh_tokens', ['user_id', 'is_revoked'], unique=False)
|
||||||
|
|
||||||
|
op.create_table('user_onboarding_progress',
|
||||||
|
sa.Column('id', sa.UUID(), nullable=False),
|
||||||
|
sa.Column('user_id', sa.UUID(), nullable=False),
|
||||||
|
sa.Column('step_name', sa.String(length=50), nullable=False),
|
||||||
|
sa.Column('completed', sa.Boolean(), nullable=False),
|
||||||
|
sa.Column('completed_at', sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column('step_data', sa.JSON(), nullable=True),
|
||||||
|
sa.Column('created_at', sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'),
|
||||||
|
sa.PrimaryKeyConstraint('id'),
|
||||||
|
sa.UniqueConstraint('user_id', 'step_name', name='uq_user_step')
|
||||||
|
)
|
||||||
|
op.create_index(op.f('ix_user_onboarding_progress_user_id'), 'user_onboarding_progress', ['user_id'], unique=False)
|
||||||
|
|
||||||
|
op.create_table('user_onboarding_summary',
|
||||||
|
sa.Column('id', sa.UUID(), nullable=False),
|
||||||
|
sa.Column('user_id', sa.UUID(), nullable=False),
|
||||||
|
sa.Column('current_step', sa.String(length=50), nullable=False),
|
||||||
|
sa.Column('next_step', sa.String(length=50), nullable=True),
|
||||||
|
sa.Column('completion_percentage', sa.String(length=50), nullable=True),
|
||||||
|
sa.Column('fully_completed', sa.Boolean(), nullable=True),
|
||||||
|
sa.Column('steps_completed_count', sa.String(length=50), nullable=True),
|
||||||
|
sa.Column('created_at', sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column('last_activity_at', sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'),
|
||||||
|
sa.PrimaryKeyConstraint('id')
|
||||||
|
)
|
||||||
|
op.create_index(op.f('ix_user_onboarding_summary_user_id'), 'user_onboarding_summary', ['user_id'], unique=True)
|
||||||
|
|
||||||
|
op.create_table('password_reset_tokens',
|
||||||
|
sa.Column('id', postgresql.UUID(as_uuid=True), nullable=False),
|
||||||
|
sa.Column('user_id', postgresql.UUID(as_uuid=True), nullable=False),
|
||||||
|
sa.Column('token', sa.String(length=255), nullable=False),
|
||||||
|
sa.Column('expires_at', sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.Column('is_used', sa.Boolean(), nullable=False, default=False),
|
||||||
|
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False,
|
||||||
|
server_default=sa.text("timezone('utc', CURRENT_TIMESTAMP)")),
|
||||||
|
sa.Column('used_at', sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.PrimaryKeyConstraint('id'),
|
||||||
|
sa.UniqueConstraint('token'),
|
||||||
|
)
|
||||||
|
op.create_index('ix_password_reset_tokens_user_id', 'password_reset_tokens', ['user_id'])
|
||||||
|
op.create_index('ix_password_reset_tokens_token', 'password_reset_tokens', ['token'])
|
||||||
|
op.create_index('ix_password_reset_tokens_expires_at', 'password_reset_tokens', ['expires_at'])
|
||||||
|
op.create_index('ix_password_reset_tokens_is_used', 'password_reset_tokens', ['is_used'])
|
||||||
|
|
||||||
|
# GDPR consent tables
|
||||||
|
op.create_table('user_consents',
|
||||||
|
sa.Column('id', sa.UUID(), nullable=False),
|
||||||
|
sa.Column('user_id', sa.UUID(), nullable=False),
|
||||||
|
sa.Column('terms_accepted', sa.Boolean(), nullable=False),
|
||||||
|
sa.Column('privacy_accepted', sa.Boolean(), nullable=False),
|
||||||
|
sa.Column('marketing_consent', sa.Boolean(), nullable=False),
|
||||||
|
sa.Column('analytics_consent', sa.Boolean(), nullable=False),
|
||||||
|
sa.Column('consent_version', sa.String(length=20), nullable=False),
|
||||||
|
sa.Column('consent_method', sa.String(length=50), nullable=False),
|
||||||
|
sa.Column('ip_address', sa.String(length=45), nullable=True),
|
||||||
|
sa.Column('user_agent', sa.Text(), nullable=True),
|
||||||
|
sa.Column('terms_text_hash', sa.String(length=64), nullable=True),
|
||||||
|
sa.Column('privacy_text_hash', sa.String(length=64), nullable=True),
|
||||||
|
sa.Column('consented_at', sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.Column('withdrawn_at', sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column('extra_data', postgresql.JSON(astext_type=sa.Text()), nullable=True),
|
||||||
|
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'),
|
||||||
|
sa.PrimaryKeyConstraint('id')
|
||||||
|
)
|
||||||
|
op.create_index('idx_user_consent_consented_at', 'user_consents', ['consented_at'], unique=False)
|
||||||
|
op.create_index('idx_user_consent_user_id', 'user_consents', ['user_id'], unique=False)
|
||||||
|
op.create_index(op.f('ix_user_consents_user_id'), 'user_consents', ['user_id'], unique=False)
|
||||||
|
|
||||||
|
op.create_table('consent_history',
|
||||||
|
sa.Column('id', sa.UUID(), nullable=False),
|
||||||
|
sa.Column('user_id', sa.UUID(), nullable=False),
|
||||||
|
sa.Column('consent_id', sa.UUID(), nullable=True),
|
||||||
|
sa.Column('action', sa.String(length=50), nullable=False),
|
||||||
|
sa.Column('consent_snapshot', postgresql.JSON(astext_type=sa.Text()), nullable=False),
|
||||||
|
sa.Column('ip_address', sa.String(length=45), nullable=True),
|
||||||
|
sa.Column('user_agent', sa.Text(), nullable=True),
|
||||||
|
sa.Column('consent_method', sa.String(length=50), nullable=True),
|
||||||
|
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(['consent_id'], ['user_consents.id'], ondelete='SET NULL'),
|
||||||
|
sa.PrimaryKeyConstraint('id')
|
||||||
|
)
|
||||||
|
op.create_index('idx_consent_history_action', 'consent_history', ['action'], unique=False)
|
||||||
|
op.create_index('idx_consent_history_created_at', 'consent_history', ['created_at'], unique=False)
|
||||||
|
op.create_index('idx_consent_history_user_id', 'consent_history', ['user_id'], unique=False)
|
||||||
|
op.create_index(op.f('ix_consent_history_created_at'), 'consent_history', ['created_at'], unique=False)
|
||||||
|
op.create_index(op.f('ix_consent_history_user_id'), 'consent_history', ['user_id'], unique=False)
|
||||||
|
|
||||||
|
# Audit logs table (with tenant_id nullable as per the last migration)
|
||||||
op.create_table('audit_logs',
|
op.create_table('audit_logs',
|
||||||
sa.Column('id', sa.UUID(), nullable=False),
|
sa.Column('id', sa.UUID(), nullable=False),
|
||||||
sa.Column('tenant_id', sa.UUID(), nullable=False),
|
sa.Column('tenant_id', sa.UUID(), nullable=True), # Made nullable per last migration
|
||||||
sa.Column('user_id', sa.UUID(), nullable=False),
|
sa.Column('user_id', sa.UUID(), nullable=False),
|
||||||
sa.Column('action', sa.String(length=100), nullable=False),
|
sa.Column('action', sa.String(length=100), nullable=False),
|
||||||
sa.Column('resource_type', sa.String(length=100), nullable=False),
|
sa.Column('resource_type', sa.String(length=100), nullable=False),
|
||||||
@@ -52,97 +205,10 @@ def upgrade() -> None:
|
|||||||
op.create_index(op.f('ix_audit_logs_severity'), 'audit_logs', ['severity'], unique=False)
|
op.create_index(op.f('ix_audit_logs_severity'), 'audit_logs', ['severity'], unique=False)
|
||||||
op.create_index(op.f('ix_audit_logs_tenant_id'), 'audit_logs', ['tenant_id'], unique=False)
|
op.create_index(op.f('ix_audit_logs_tenant_id'), 'audit_logs', ['tenant_id'], unique=False)
|
||||||
op.create_index(op.f('ix_audit_logs_user_id'), 'audit_logs', ['user_id'], unique=False)
|
op.create_index(op.f('ix_audit_logs_user_id'), 'audit_logs', ['user_id'], unique=False)
|
||||||
op.create_table('login_attempts',
|
|
||||||
sa.Column('id', sa.UUID(), nullable=False),
|
|
||||||
sa.Column('email', sa.String(length=255), nullable=False),
|
|
||||||
sa.Column('ip_address', sa.String(length=45), nullable=False),
|
|
||||||
sa.Column('user_agent', sa.Text(), nullable=True),
|
|
||||||
sa.Column('success', sa.Boolean(), nullable=True),
|
|
||||||
sa.Column('failure_reason', sa.String(length=255), nullable=True),
|
|
||||||
sa.Column('created_at', sa.DateTime(timezone=True), nullable=True),
|
|
||||||
sa.PrimaryKeyConstraint('id')
|
|
||||||
)
|
|
||||||
op.create_index(op.f('ix_login_attempts_email'), 'login_attempts', ['email'], unique=False)
|
|
||||||
op.create_table('refresh_tokens',
|
|
||||||
sa.Column('id', sa.UUID(), nullable=False),
|
|
||||||
sa.Column('user_id', sa.UUID(), nullable=False),
|
|
||||||
sa.Column('token', sa.Text(), nullable=False),
|
|
||||||
sa.Column('token_hash', sa.String(length=255), nullable=True),
|
|
||||||
sa.Column('expires_at', sa.DateTime(timezone=True), nullable=False),
|
|
||||||
sa.Column('is_revoked', sa.Boolean(), nullable=False),
|
|
||||||
sa.Column('created_at', sa.DateTime(timezone=True), nullable=True),
|
|
||||||
sa.Column('revoked_at', sa.DateTime(timezone=True), nullable=True),
|
|
||||||
sa.PrimaryKeyConstraint('id'),
|
|
||||||
sa.UniqueConstraint('token_hash')
|
|
||||||
)
|
|
||||||
op.create_index('ix_refresh_tokens_expires_at', 'refresh_tokens', ['expires_at'], unique=False)
|
|
||||||
op.create_index('ix_refresh_tokens_token_hash', 'refresh_tokens', ['token_hash'], unique=False)
|
|
||||||
op.create_index(op.f('ix_refresh_tokens_user_id'), 'refresh_tokens', ['user_id'], unique=False)
|
|
||||||
op.create_index('ix_refresh_tokens_user_id_active', 'refresh_tokens', ['user_id', 'is_revoked'], unique=False)
|
|
||||||
op.create_table('users',
|
|
||||||
sa.Column('id', sa.UUID(), nullable=False),
|
|
||||||
sa.Column('email', sa.String(length=255), nullable=False),
|
|
||||||
sa.Column('hashed_password', sa.String(length=255), nullable=False),
|
|
||||||
sa.Column('full_name', sa.String(length=255), nullable=False),
|
|
||||||
sa.Column('is_active', sa.Boolean(), nullable=True),
|
|
||||||
sa.Column('is_verified', sa.Boolean(), nullable=True),
|
|
||||||
sa.Column('created_at', sa.DateTime(timezone=True), nullable=True),
|
|
||||||
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=True),
|
|
||||||
sa.Column('last_login', sa.DateTime(timezone=True), nullable=True),
|
|
||||||
sa.Column('phone', sa.String(length=20), nullable=True),
|
|
||||||
sa.Column('language', sa.String(length=10), nullable=True),
|
|
||||||
sa.Column('timezone', sa.String(length=50), nullable=True),
|
|
||||||
sa.Column('role', sa.String(length=20), nullable=False),
|
|
||||||
sa.PrimaryKeyConstraint('id')
|
|
||||||
)
|
|
||||||
op.create_index(op.f('ix_users_email'), 'users', ['email'], unique=True)
|
|
||||||
op.create_table('user_onboarding_progress',
|
|
||||||
sa.Column('id', sa.UUID(), nullable=False),
|
|
||||||
sa.Column('user_id', sa.UUID(), nullable=False),
|
|
||||||
sa.Column('step_name', sa.String(length=50), nullable=False),
|
|
||||||
sa.Column('completed', sa.Boolean(), nullable=False),
|
|
||||||
sa.Column('completed_at', sa.DateTime(timezone=True), nullable=True),
|
|
||||||
sa.Column('step_data', sa.JSON(), nullable=True),
|
|
||||||
sa.Column('created_at', sa.DateTime(timezone=True), nullable=True),
|
|
||||||
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=True),
|
|
||||||
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'),
|
|
||||||
sa.PrimaryKeyConstraint('id'),
|
|
||||||
sa.UniqueConstraint('user_id', 'step_name', name='uq_user_step')
|
|
||||||
)
|
|
||||||
op.create_index(op.f('ix_user_onboarding_progress_user_id'), 'user_onboarding_progress', ['user_id'], unique=False)
|
|
||||||
op.create_table('user_onboarding_summary',
|
|
||||||
sa.Column('id', sa.UUID(), nullable=False),
|
|
||||||
sa.Column('user_id', sa.UUID(), nullable=False),
|
|
||||||
sa.Column('current_step', sa.String(length=50), nullable=False),
|
|
||||||
sa.Column('next_step', sa.String(length=50), nullable=True),
|
|
||||||
sa.Column('completion_percentage', sa.String(length=50), nullable=True),
|
|
||||||
sa.Column('fully_completed', sa.Boolean(), nullable=True),
|
|
||||||
sa.Column('steps_completed_count', sa.String(length=50), nullable=True),
|
|
||||||
sa.Column('created_at', sa.DateTime(timezone=True), nullable=True),
|
|
||||||
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=True),
|
|
||||||
sa.Column('last_activity_at', sa.DateTime(timezone=True), nullable=True),
|
|
||||||
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'),
|
|
||||||
sa.PrimaryKeyConstraint('id')
|
|
||||||
)
|
|
||||||
op.create_index(op.f('ix_user_onboarding_summary_user_id'), 'user_onboarding_summary', ['user_id'], unique=True)
|
|
||||||
# ### end Alembic commands ###
|
|
||||||
|
|
||||||
|
|
||||||
def downgrade() -> None:
|
def downgrade() -> None:
|
||||||
# ### commands auto generated by Alembic - please adjust! ###
|
# Drop tables in reverse order (respecting foreign key dependencies)
|
||||||
op.drop_index(op.f('ix_user_onboarding_summary_user_id'), table_name='user_onboarding_summary')
|
|
||||||
op.drop_table('user_onboarding_summary')
|
|
||||||
op.drop_index(op.f('ix_user_onboarding_progress_user_id'), table_name='user_onboarding_progress')
|
|
||||||
op.drop_table('user_onboarding_progress')
|
|
||||||
op.drop_index(op.f('ix_users_email'), table_name='users')
|
|
||||||
op.drop_table('users')
|
|
||||||
op.drop_index('ix_refresh_tokens_user_id_active', table_name='refresh_tokens')
|
|
||||||
op.drop_index(op.f('ix_refresh_tokens_user_id'), table_name='refresh_tokens')
|
|
||||||
op.drop_index('ix_refresh_tokens_token_hash', table_name='refresh_tokens')
|
|
||||||
op.drop_index('ix_refresh_tokens_expires_at', table_name='refresh_tokens')
|
|
||||||
op.drop_table('refresh_tokens')
|
|
||||||
op.drop_index(op.f('ix_login_attempts_email'), table_name='login_attempts')
|
|
||||||
op.drop_table('login_attempts')
|
|
||||||
op.drop_index(op.f('ix_audit_logs_user_id'), table_name='audit_logs')
|
op.drop_index(op.f('ix_audit_logs_user_id'), table_name='audit_logs')
|
||||||
op.drop_index(op.f('ix_audit_logs_tenant_id'), table_name='audit_logs')
|
op.drop_index(op.f('ix_audit_logs_tenant_id'), table_name='audit_logs')
|
||||||
op.drop_index(op.f('ix_audit_logs_severity'), table_name='audit_logs')
|
op.drop_index(op.f('ix_audit_logs_severity'), table_name='audit_logs')
|
||||||
@@ -157,4 +223,40 @@ def downgrade() -> None:
|
|||||||
op.drop_index('idx_audit_service_created', table_name='audit_logs')
|
op.drop_index('idx_audit_service_created', table_name='audit_logs')
|
||||||
op.drop_index('idx_audit_resource_type_action', table_name='audit_logs')
|
op.drop_index('idx_audit_resource_type_action', table_name='audit_logs')
|
||||||
op.drop_table('audit_logs')
|
op.drop_table('audit_logs')
|
||||||
# ### end Alembic commands ###
|
|
||||||
|
op.drop_index(op.f('ix_consent_history_user_id'), table_name='consent_history')
|
||||||
|
op.drop_index(op.f('ix_consent_history_created_at'), table_name='consent_history')
|
||||||
|
op.drop_index('idx_consent_history_user_id', table_name='consent_history')
|
||||||
|
op.drop_index('idx_consent_history_created_at', table_name='consent_history')
|
||||||
|
op.drop_index('idx_consent_history_action', table_name='consent_history')
|
||||||
|
op.drop_table('consent_history')
|
||||||
|
|
||||||
|
op.drop_index(op.f('ix_user_consents_user_id'), table_name='user_consents')
|
||||||
|
op.drop_index('idx_user_consent_user_id', table_name='user_consents')
|
||||||
|
op.drop_index('idx_user_consent_consented_at', table_name='user_consents')
|
||||||
|
op.drop_table('user_consents')
|
||||||
|
|
||||||
|
op.drop_index('ix_password_reset_tokens_is_used', table_name='password_reset_tokens')
|
||||||
|
op.drop_index('ix_password_reset_tokens_expires_at', table_name='password_reset_tokens')
|
||||||
|
op.drop_index('ix_password_reset_tokens_token', table_name='password_reset_tokens')
|
||||||
|
op.drop_index('ix_password_reset_tokens_user_id', table_name='password_reset_tokens')
|
||||||
|
op.drop_table('password_reset_tokens')
|
||||||
|
|
||||||
|
op.drop_index(op.f('ix_user_onboarding_summary_user_id'), table_name='user_onboarding_summary')
|
||||||
|
op.drop_table('user_onboarding_summary')
|
||||||
|
|
||||||
|
op.drop_index(op.f('ix_user_onboarding_progress_user_id'), table_name='user_onboarding_progress')
|
||||||
|
op.drop_table('user_onboarding_progress')
|
||||||
|
|
||||||
|
op.drop_index('ix_refresh_tokens_user_id_active', table_name='refresh_tokens')
|
||||||
|
op.drop_index(op.f('ix_refresh_tokens_user_id'), table_name='refresh_tokens')
|
||||||
|
op.drop_index('ix_refresh_tokens_token_hash', table_name='refresh_tokens')
|
||||||
|
op.drop_index('ix_refresh_tokens_expires_at', table_name='refresh_tokens')
|
||||||
|
op.drop_table('refresh_tokens')
|
||||||
|
|
||||||
|
op.drop_index(op.f('ix_login_attempts_email'), table_name='login_attempts')
|
||||||
|
op.drop_table('login_attempts')
|
||||||
|
|
||||||
|
op.drop_index(op.f('ix_users_payment_customer_id'), table_name='users')
|
||||||
|
op.drop_index(op.f('ix_users_email'), table_name='users')
|
||||||
|
op.drop_table('users')
|
||||||
@@ -737,15 +737,27 @@ async def validate_plan_upgrade(
|
|||||||
async def upgrade_subscription_plan(
|
async def upgrade_subscription_plan(
|
||||||
tenant_id: str = Path(..., description="Tenant ID"),
|
tenant_id: str = Path(..., description="Tenant ID"),
|
||||||
new_plan: str = Query(..., description="New plan name"),
|
new_plan: str = Query(..., description="New plan name"),
|
||||||
|
billing_cycle: str = Query("monthly", description="Billing cycle (monthly/yearly)"),
|
||||||
current_user: Dict[str, Any] = Depends(get_current_user_dep),
|
current_user: Dict[str, Any] = Depends(get_current_user_dep),
|
||||||
limit_service: SubscriptionLimitService = Depends(get_subscription_limit_service)
|
limit_service: SubscriptionLimitService = Depends(get_subscription_limit_service),
|
||||||
|
orchestration_service: SubscriptionOrchestrationService = Depends(get_subscription_orchestration_service)
|
||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
"""
|
"""
|
||||||
Upgrade subscription plan for a tenant
|
Upgrade subscription plan for a tenant
|
||||||
|
|
||||||
Includes validation, cache invalidation, and token refresh.
|
This endpoint handles:
|
||||||
|
- Plan upgrade validation
|
||||||
|
- Stripe subscription update (preserves trial status if in trial)
|
||||||
|
- Local database update
|
||||||
|
- Cache invalidation
|
||||||
|
- Token refresh for immediate UI update
|
||||||
|
|
||||||
|
Trial handling:
|
||||||
|
- If user is in trial, they remain in trial after upgrade
|
||||||
|
- The upgraded tier price will be charged when trial ends
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
|
# Step 1: Validate upgrade eligibility
|
||||||
validation = await limit_service.validate_plan_upgrade(tenant_id, new_plan)
|
validation = await limit_service.validate_plan_upgrade(tenant_id, new_plan)
|
||||||
if not validation.get("can_upgrade", False):
|
if not validation.get("can_upgrade", False):
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
@@ -768,22 +780,77 @@ async def upgrade_subscription_plan(
|
|||||||
detail="No active subscription found for this tenant"
|
detail="No active subscription found for this tenant"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
old_plan = active_subscription.plan
|
||||||
|
is_trialing = active_subscription.status == 'trialing'
|
||||||
|
trial_ends_at = active_subscription.trial_ends_at
|
||||||
|
|
||||||
|
logger.info("Starting subscription upgrade",
|
||||||
|
extra={
|
||||||
|
"tenant_id": tenant_id,
|
||||||
|
"subscription_id": str(active_subscription.id),
|
||||||
|
"stripe_subscription_id": active_subscription.subscription_id,
|
||||||
|
"old_plan": old_plan,
|
||||||
|
"new_plan": new_plan,
|
||||||
|
"is_trialing": is_trialing,
|
||||||
|
"trial_ends_at": str(trial_ends_at) if trial_ends_at else None,
|
||||||
|
"user_id": current_user["user_id"]
|
||||||
|
})
|
||||||
|
|
||||||
|
# Step 2: Update Stripe subscription if Stripe subscription ID exists
|
||||||
|
stripe_updated = False
|
||||||
|
if active_subscription.subscription_id:
|
||||||
|
try:
|
||||||
|
# Use orchestration service to handle Stripe update with trial preservation
|
||||||
|
upgrade_result = await orchestration_service.orchestrate_plan_upgrade(
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
new_plan=new_plan,
|
||||||
|
proration_behavior="none" if is_trialing else "create_prorations",
|
||||||
|
immediate_change=not is_trialing, # Don't change billing anchor if trialing
|
||||||
|
billing_cycle=billing_cycle
|
||||||
|
)
|
||||||
|
stripe_updated = True
|
||||||
|
logger.info("Stripe subscription updated successfully",
|
||||||
|
extra={
|
||||||
|
"tenant_id": tenant_id,
|
||||||
|
"stripe_subscription_id": active_subscription.subscription_id,
|
||||||
|
"upgrade_result": upgrade_result
|
||||||
|
})
|
||||||
|
except Exception as stripe_error:
|
||||||
|
logger.error("Failed to update Stripe subscription, falling back to local update only",
|
||||||
|
extra={"tenant_id": tenant_id, "error": str(stripe_error)})
|
||||||
|
# Continue with local update even if Stripe fails
|
||||||
|
# This ensures the user gets access to features immediately
|
||||||
|
|
||||||
|
# Step 3: Update local database
|
||||||
updated_subscription = await subscription_repo.update_subscription_plan(
|
updated_subscription = await subscription_repo.update_subscription_plan(
|
||||||
str(active_subscription.id),
|
str(active_subscription.id),
|
||||||
new_plan
|
new_plan
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Preserve trial status if was trialing
|
||||||
|
if is_trialing and trial_ends_at:
|
||||||
|
# Ensure trial_ends_at is preserved after plan update
|
||||||
|
await subscription_repo.update_subscription_status(
|
||||||
|
str(active_subscription.id),
|
||||||
|
'trialing',
|
||||||
|
{'trial_ends_at': trial_ends_at}
|
||||||
|
)
|
||||||
|
|
||||||
await session.commit()
|
await session.commit()
|
||||||
|
|
||||||
logger.info("Subscription plan upgraded successfully",
|
logger.info("Subscription plan upgraded successfully in database",
|
||||||
extra={
|
extra={
|
||||||
"tenant_id": tenant_id,
|
"tenant_id": tenant_id,
|
||||||
"subscription_id": str(active_subscription.id),
|
"subscription_id": str(active_subscription.id),
|
||||||
"old_plan": active_subscription.plan,
|
"old_plan": old_plan,
|
||||||
"new_plan": new_plan,
|
"new_plan": new_plan,
|
||||||
|
"stripe_updated": stripe_updated,
|
||||||
|
"preserved_trial": is_trialing,
|
||||||
"user_id": current_user["user_id"]
|
"user_id": current_user["user_id"]
|
||||||
})
|
})
|
||||||
|
|
||||||
|
# Step 4: Invalidate subscription cache
|
||||||
|
redis_client = None
|
||||||
try:
|
try:
|
||||||
from app.services.subscription_cache import get_subscription_cache_service
|
from app.services.subscription_cache import get_subscription_cache_service
|
||||||
|
|
||||||
@@ -797,14 +864,17 @@ async def upgrade_subscription_plan(
|
|||||||
logger.error("Failed to invalidate subscription cache after upgrade",
|
logger.error("Failed to invalidate subscription cache after upgrade",
|
||||||
extra={"tenant_id": tenant_id, "error": str(cache_error)})
|
extra={"tenant_id": tenant_id, "error": str(cache_error)})
|
||||||
|
|
||||||
|
# Step 5: Invalidate tokens for immediate UI refresh
|
||||||
try:
|
try:
|
||||||
await _invalidate_tenant_tokens(tenant_id, redis_client)
|
if redis_client:
|
||||||
logger.info("Invalidated all tokens for tenant after subscription upgrade",
|
await _invalidate_tenant_tokens(tenant_id, redis_client)
|
||||||
extra={"tenant_id": tenant_id})
|
logger.info("Invalidated all tokens for tenant after subscription upgrade",
|
||||||
|
extra={"tenant_id": tenant_id})
|
||||||
except Exception as token_error:
|
except Exception as token_error:
|
||||||
logger.error("Failed to invalidate tenant tokens after upgrade",
|
logger.error("Failed to invalidate tenant tokens after upgrade",
|
||||||
extra={"tenant_id": tenant_id, "error": str(token_error)})
|
extra={"tenant_id": tenant_id, "error": str(token_error)})
|
||||||
|
|
||||||
|
# Step 6: Publish subscription change event for other services
|
||||||
try:
|
try:
|
||||||
from shared.messaging import UnifiedEventPublisher
|
from shared.messaging import UnifiedEventPublisher
|
||||||
event_publisher = UnifiedEventPublisher()
|
event_publisher = UnifiedEventPublisher()
|
||||||
@@ -813,9 +883,12 @@ async def upgrade_subscription_plan(
|
|||||||
tenant_id=tenant_id,
|
tenant_id=tenant_id,
|
||||||
data={
|
data={
|
||||||
"tenant_id": tenant_id,
|
"tenant_id": tenant_id,
|
||||||
"old_tier": active_subscription.plan,
|
"old_tier": old_plan,
|
||||||
"new_tier": new_plan,
|
"new_tier": new_plan,
|
||||||
"action": "upgrade"
|
"action": "upgrade",
|
||||||
|
"is_trialing": is_trialing,
|
||||||
|
"trial_ends_at": trial_ends_at.isoformat() if trial_ends_at else None,
|
||||||
|
"stripe_updated": stripe_updated
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
logger.info("Published subscription change event",
|
logger.info("Published subscription change event",
|
||||||
@@ -826,10 +899,13 @@ async def upgrade_subscription_plan(
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
"success": True,
|
"success": True,
|
||||||
"message": f"Plan successfully upgraded to {new_plan}",
|
"message": f"Plan successfully upgraded to {new_plan}" + (" (trial preserved)" if is_trialing else ""),
|
||||||
"old_plan": active_subscription.plan,
|
"old_plan": old_plan,
|
||||||
"new_plan": new_plan,
|
"new_plan": new_plan,
|
||||||
"new_monthly_price": updated_subscription.monthly_price,
|
"new_monthly_price": updated_subscription.monthly_price,
|
||||||
|
"is_trialing": is_trialing,
|
||||||
|
"trial_ends_at": trial_ends_at.isoformat() if trial_ends_at else None,
|
||||||
|
"stripe_updated": stripe_updated,
|
||||||
"validation": validation,
|
"validation": validation,
|
||||||
"requires_token_refresh": True
|
"requires_token_refresh": True
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -114,10 +114,12 @@ async def register_bakery(
|
|||||||
error=str(linking_error))
|
error=str(linking_error))
|
||||||
|
|
||||||
elif bakery_data.coupon_code:
|
elif bakery_data.coupon_code:
|
||||||
coupon_validation = payment_service.validate_coupon_code(
|
from app.services.coupon_service import CouponService
|
||||||
|
|
||||||
|
coupon_service = CouponService(db)
|
||||||
|
coupon_validation = await coupon_service.validate_coupon_code(
|
||||||
bakery_data.coupon_code,
|
bakery_data.coupon_code,
|
||||||
tenant_id,
|
tenant_id
|
||||||
db
|
|
||||||
)
|
)
|
||||||
|
|
||||||
if not coupon_validation["valid"]:
|
if not coupon_validation["valid"]:
|
||||||
@@ -131,10 +133,10 @@ async def register_bakery(
|
|||||||
detail=coupon_validation["error_message"]
|
detail=coupon_validation["error_message"]
|
||||||
)
|
)
|
||||||
|
|
||||||
success, discount, error = payment_service.redeem_coupon(
|
success, discount, error = await coupon_service.redeem_coupon(
|
||||||
bakery_data.coupon_code,
|
bakery_data.coupon_code,
|
||||||
tenant_id,
|
tenant_id,
|
||||||
db
|
base_trial_days=0
|
||||||
)
|
)
|
||||||
|
|
||||||
if success:
|
if success:
|
||||||
@@ -194,13 +196,15 @@ async def register_bakery(
|
|||||||
|
|
||||||
if coupon_validation and coupon_validation["valid"]:
|
if coupon_validation and coupon_validation["valid"]:
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
|
from app.services.coupon_service import CouponService
|
||||||
database_manager = create_database_manager(settings.DATABASE_URL, "tenant-service")
|
database_manager = create_database_manager(settings.DATABASE_URL, "tenant-service")
|
||||||
|
|
||||||
async with database_manager.get_session() as session:
|
async with database_manager.get_session() as session:
|
||||||
success, discount, error = payment_service.redeem_coupon(
|
coupon_service = CouponService(session)
|
||||||
|
success, discount, error = await coupon_service.redeem_coupon(
|
||||||
bakery_data.coupon_code,
|
bakery_data.coupon_code,
|
||||||
result.id,
|
result.id,
|
||||||
session
|
base_trial_days=0
|
||||||
)
|
)
|
||||||
|
|
||||||
if success:
|
if success:
|
||||||
|
|||||||
@@ -247,6 +247,50 @@ class SubscriptionRepository(TenantBaseRepository):
|
|||||||
error=str(e))
|
error=str(e))
|
||||||
raise DatabaseError(f"Failed to update plan: {str(e)}")
|
raise DatabaseError(f"Failed to update plan: {str(e)}")
|
||||||
|
|
||||||
|
async def update_subscription_status(
|
||||||
|
self,
|
||||||
|
subscription_id: str,
|
||||||
|
status: str,
|
||||||
|
additional_data: Dict[str, Any] = None
|
||||||
|
) -> Optional[Subscription]:
|
||||||
|
"""Update subscription status with optional additional data"""
|
||||||
|
try:
|
||||||
|
# Get subscription to find tenant_id for cache invalidation
|
||||||
|
subscription = await self.get_by_id(subscription_id)
|
||||||
|
if not subscription:
|
||||||
|
raise ValidationError(f"Subscription {subscription_id} not found")
|
||||||
|
|
||||||
|
update_data = {
|
||||||
|
"status": status,
|
||||||
|
"updated_at": datetime.utcnow()
|
||||||
|
}
|
||||||
|
|
||||||
|
# Merge additional data if provided
|
||||||
|
if additional_data:
|
||||||
|
update_data.update(additional_data)
|
||||||
|
|
||||||
|
updated_subscription = await self.update(subscription_id, update_data)
|
||||||
|
|
||||||
|
# Invalidate cache
|
||||||
|
if subscription.tenant_id:
|
||||||
|
await self._invalidate_cache(str(subscription.tenant_id))
|
||||||
|
|
||||||
|
logger.info("Subscription status updated",
|
||||||
|
subscription_id=subscription_id,
|
||||||
|
new_status=status,
|
||||||
|
additional_data=additional_data)
|
||||||
|
|
||||||
|
return updated_subscription
|
||||||
|
|
||||||
|
except ValidationError:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("Failed to update subscription status",
|
||||||
|
subscription_id=subscription_id,
|
||||||
|
status=status,
|
||||||
|
error=str(e))
|
||||||
|
raise DatabaseError(f"Failed to update subscription status: {str(e)}")
|
||||||
|
|
||||||
async def cancel_subscription(
|
async def cancel_subscription(
|
||||||
self,
|
self,
|
||||||
subscription_id: str,
|
subscription_id: str,
|
||||||
|
|||||||
@@ -852,7 +852,8 @@ class PaymentService:
|
|||||||
proration_behavior: str = "create_prorations",
|
proration_behavior: str = "create_prorations",
|
||||||
billing_cycle_anchor: Optional[str] = None,
|
billing_cycle_anchor: Optional[str] = None,
|
||||||
payment_behavior: str = "error_if_incomplete",
|
payment_behavior: str = "error_if_incomplete",
|
||||||
immediate_change: bool = True
|
immediate_change: bool = True,
|
||||||
|
preserve_trial: bool = False
|
||||||
) -> Any:
|
) -> Any:
|
||||||
"""
|
"""
|
||||||
Update subscription price (plan upgrade/downgrade)
|
Update subscription price (plan upgrade/downgrade)
|
||||||
@@ -860,24 +861,33 @@ class PaymentService:
|
|||||||
Args:
|
Args:
|
||||||
subscription_id: Stripe subscription ID
|
subscription_id: Stripe subscription ID
|
||||||
new_price_id: New Stripe price ID
|
new_price_id: New Stripe price ID
|
||||||
proration_behavior: How to handle proration
|
proration_behavior: How to handle proration ('create_prorations', 'none', 'always_invoice')
|
||||||
billing_cycle_anchor: Billing cycle anchor ("now" or "unchanged")
|
billing_cycle_anchor: Billing cycle anchor ("now" or "unchanged")
|
||||||
payment_behavior: Payment behavior on update
|
payment_behavior: Payment behavior on update
|
||||||
immediate_change: Whether to apply changes immediately
|
immediate_change: Whether to apply changes immediately
|
||||||
|
preserve_trial: If True, preserves the trial period after upgrade
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Updated subscription object with .id, .status, etc. attributes
|
Updated subscription object with .id, .status, etc. attributes
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
result = await retry_with_backoff(
|
result = await retry_with_backoff(
|
||||||
lambda: self.stripe_client.update_subscription(subscription_id, new_price_id),
|
lambda: self.stripe_client.update_subscription(
|
||||||
|
subscription_id,
|
||||||
|
new_price_id,
|
||||||
|
proration_behavior=proration_behavior,
|
||||||
|
preserve_trial=preserve_trial
|
||||||
|
),
|
||||||
max_retries=3,
|
max_retries=3,
|
||||||
exceptions=(SubscriptionCreationFailed,)
|
exceptions=(SubscriptionCreationFailed,)
|
||||||
)
|
)
|
||||||
|
|
||||||
logger.info("Subscription updated successfully",
|
logger.info("Subscription updated successfully",
|
||||||
subscription_id=subscription_id,
|
subscription_id=subscription_id,
|
||||||
new_price_id=new_price_id)
|
new_price_id=new_price_id,
|
||||||
|
proration_behavior=proration_behavior,
|
||||||
|
preserve_trial=preserve_trial,
|
||||||
|
is_trialing=result.get('is_trialing', False))
|
||||||
|
|
||||||
# Create wrapper object for compatibility with callers expecting .id, .status etc.
|
# Create wrapper object for compatibility with callers expecting .id, .status etc.
|
||||||
class SubscriptionWrapper:
|
class SubscriptionWrapper:
|
||||||
@@ -887,6 +897,8 @@ class PaymentService:
|
|||||||
self.current_period_start = data.get('current_period_start')
|
self.current_period_start = data.get('current_period_start')
|
||||||
self.current_period_end = data.get('current_period_end')
|
self.current_period_end = data.get('current_period_end')
|
||||||
self.customer = data.get('customer_id')
|
self.customer = data.get('customer_id')
|
||||||
|
self.trial_end = data.get('trial_end')
|
||||||
|
self.is_trialing = data.get('is_trialing', False)
|
||||||
|
|
||||||
return SubscriptionWrapper(result)
|
return SubscriptionWrapper(result)
|
||||||
|
|
||||||
|
|||||||
@@ -638,17 +638,22 @@ class SubscriptionOrchestrationService:
|
|||||||
billing_cycle: str = "monthly"
|
billing_cycle: str = "monthly"
|
||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
"""
|
"""
|
||||||
Orchestrate plan upgrade workflow with proration
|
Orchestrate plan upgrade workflow with proration and trial preservation
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
tenant_id: Tenant ID
|
tenant_id: Tenant ID
|
||||||
new_plan: New plan name
|
new_plan: New plan name
|
||||||
proration_behavior: Proration behavior
|
proration_behavior: Proration behavior ('create_prorations', 'none', 'always_invoice')
|
||||||
immediate_change: Whether to apply changes immediately
|
immediate_change: Whether to apply changes immediately
|
||||||
billing_cycle: Billing cycle for new plan
|
billing_cycle: Billing cycle for new plan
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Dictionary with upgrade results
|
Dictionary with upgrade results
|
||||||
|
|
||||||
|
Trial Handling:
|
||||||
|
- If subscription is in trial, the trial period is preserved
|
||||||
|
- No proration charges are created during trial
|
||||||
|
- After trial ends, the user is charged at the new tier price
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
logger.info("Starting plan upgrade orchestration",
|
logger.info("Starting plan upgrade orchestration",
|
||||||
@@ -665,33 +670,64 @@ class SubscriptionOrchestrationService:
|
|||||||
if not subscription.subscription_id:
|
if not subscription.subscription_id:
|
||||||
raise ValidationError(f"Tenant {tenant_id} does not have a Stripe subscription ID")
|
raise ValidationError(f"Tenant {tenant_id} does not have a Stripe subscription ID")
|
||||||
|
|
||||||
|
# Step 1.5: Check if subscription is in trial
|
||||||
|
is_trialing = subscription.status == 'trialing'
|
||||||
|
trial_ends_at = subscription.trial_ends_at
|
||||||
|
|
||||||
|
logger.info("Subscription trial status",
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
is_trialing=is_trialing,
|
||||||
|
trial_ends_at=str(trial_ends_at) if trial_ends_at else None,
|
||||||
|
current_plan=subscription.plan)
|
||||||
|
|
||||||
|
# For trial subscriptions:
|
||||||
|
# - No proration charges (proration_behavior='none')
|
||||||
|
# - Preserve trial period
|
||||||
|
# - User gets new tier features immediately
|
||||||
|
# - User is charged new tier price when trial ends
|
||||||
|
if is_trialing:
|
||||||
|
proration_behavior = "none"
|
||||||
|
logger.info("Trial subscription detected, disabling proration",
|
||||||
|
tenant_id=tenant_id)
|
||||||
|
|
||||||
# Step 2: Get Stripe price ID for new plan
|
# Step 2: Get Stripe price ID for new plan
|
||||||
stripe_price_id = self.payment_service._get_stripe_price_id(new_plan, billing_cycle)
|
stripe_price_id = self.payment_service._get_stripe_price_id(new_plan, billing_cycle)
|
||||||
|
|
||||||
# Step 3: Calculate proration preview
|
# Step 3: Calculate proration preview (only if not trialing)
|
||||||
proration_details = await self.payment_service.calculate_payment_proration(
|
proration_details = {}
|
||||||
subscription.subscription_id,
|
if not is_trialing:
|
||||||
stripe_price_id,
|
proration_details = await self.payment_service.calculate_payment_proration(
|
||||||
proration_behavior
|
subscription.subscription_id,
|
||||||
)
|
stripe_price_id,
|
||||||
|
proration_behavior
|
||||||
|
)
|
||||||
|
logger.info("Proration calculated for plan upgrade",
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
proration_amount=proration_details.get("net_amount", 0))
|
||||||
|
else:
|
||||||
|
proration_details = {
|
||||||
|
"subscription_id": subscription.subscription_id,
|
||||||
|
"new_price_id": stripe_price_id,
|
||||||
|
"proration_behavior": proration_behavior,
|
||||||
|
"net_amount": 0,
|
||||||
|
"trial_preserved": True
|
||||||
|
}
|
||||||
|
|
||||||
logger.info("Proration calculated for plan upgrade",
|
# Step 4: Update in payment provider with trial preservation
|
||||||
tenant_id=tenant_id,
|
|
||||||
proration_amount=proration_details.get("net_amount", 0))
|
|
||||||
|
|
||||||
# Step 4: Update in payment provider
|
|
||||||
updated_stripe_subscription = await self.payment_service.update_payment_subscription(
|
updated_stripe_subscription = await self.payment_service.update_payment_subscription(
|
||||||
subscription.subscription_id,
|
subscription.subscription_id,
|
||||||
stripe_price_id,
|
stripe_price_id,
|
||||||
proration_behavior=proration_behavior,
|
proration_behavior=proration_behavior,
|
||||||
billing_cycle_anchor="now" if immediate_change else "unchanged",
|
billing_cycle_anchor="now" if immediate_change and not is_trialing else "unchanged",
|
||||||
payment_behavior="error_if_incomplete",
|
payment_behavior="error_if_incomplete",
|
||||||
immediate_change=immediate_change
|
immediate_change=immediate_change,
|
||||||
|
preserve_trial=is_trialing # Preserve trial if currently trialing
|
||||||
)
|
)
|
||||||
|
|
||||||
logger.info("Plan updated in payment provider",
|
logger.info("Plan updated in payment provider",
|
||||||
subscription_id=updated_stripe_subscription.id,
|
subscription_id=updated_stripe_subscription.id,
|
||||||
new_status=updated_stripe_subscription.status)
|
new_status=updated_stripe_subscription.status,
|
||||||
|
is_trialing=getattr(updated_stripe_subscription, 'is_trialing', False))
|
||||||
|
|
||||||
# Step 5: Update local subscription record
|
# Step 5: Update local subscription record
|
||||||
update_result = await self.subscription_service.update_subscription_plan_record(
|
update_result = await self.subscription_service.update_subscription_plan_record(
|
||||||
@@ -722,8 +758,12 @@ class SubscriptionOrchestrationService:
|
|||||||
logger.info("Tenant plan information updated",
|
logger.info("Tenant plan information updated",
|
||||||
tenant_id=tenant_id)
|
tenant_id=tenant_id)
|
||||||
|
|
||||||
# Add immediate_change to result
|
# Add upgrade metadata to result
|
||||||
update_result["immediate_change"] = immediate_change
|
update_result["immediate_change"] = immediate_change
|
||||||
|
update_result["is_trialing"] = is_trialing
|
||||||
|
update_result["trial_preserved"] = is_trialing
|
||||||
|
if trial_ends_at:
|
||||||
|
update_result["trial_ends_at"] = trial_ends_at.isoformat()
|
||||||
|
|
||||||
return update_result
|
return update_result
|
||||||
|
|
||||||
|
|||||||
@@ -1324,7 +1324,9 @@ class StripeClient(PaymentProvider):
|
|||||||
async def update_subscription(
|
async def update_subscription(
|
||||||
self,
|
self,
|
||||||
subscription_id: str,
|
subscription_id: str,
|
||||||
new_price_id: str
|
new_price_id: str,
|
||||||
|
proration_behavior: str = 'create_prorations',
|
||||||
|
preserve_trial: bool = False
|
||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
"""
|
"""
|
||||||
Update subscription price (plan upgrade/downgrade)
|
Update subscription price (plan upgrade/downgrade)
|
||||||
@@ -1332,35 +1334,69 @@ class StripeClient(PaymentProvider):
|
|||||||
Args:
|
Args:
|
||||||
subscription_id: Subscription ID
|
subscription_id: Subscription ID
|
||||||
new_price_id: New price ID
|
new_price_id: New price ID
|
||||||
|
proration_behavior: How to handle proration ('create_prorations', 'none', 'always_invoice')
|
||||||
|
- 'none': No proration charges (useful during trial)
|
||||||
|
- 'create_prorations': Create prorated charges (default)
|
||||||
|
- 'always_invoice': Always create an invoice
|
||||||
|
preserve_trial: If True, preserves the trial period after upgrade
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Subscription update result
|
Subscription update result including trial info
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
subscription = stripe.Subscription.retrieve(subscription_id)
|
subscription = stripe.Subscription.retrieve(subscription_id)
|
||||||
|
|
||||||
updated_subscription = stripe.Subscription.modify(
|
# Check if subscription is currently trialing
|
||||||
subscription_id,
|
is_trialing = subscription.status == 'trialing'
|
||||||
items=[{
|
trial_end = subscription.trial_end
|
||||||
|
|
||||||
|
# Build the modification params
|
||||||
|
modify_params = {
|
||||||
|
'items': [{
|
||||||
'id': subscription['items']['data'][0].id,
|
'id': subscription['items']['data'][0].id,
|
||||||
'price': new_price_id,
|
'price': new_price_id,
|
||||||
}],
|
}],
|
||||||
proration_behavior='create_prorations',
|
'proration_behavior': proration_behavior,
|
||||||
idempotency_key=f"update_sub_{uuid.uuid4()}"
|
'idempotency_key': f"update_sub_{uuid.uuid4()}"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Preserve trial period if requested and currently trialing
|
||||||
|
# When changing plans during trial, Stripe will by default end the trial
|
||||||
|
# By explicitly setting trial_end, we preserve it
|
||||||
|
if preserve_trial and is_trialing and trial_end:
|
||||||
|
modify_params['trial_end'] = trial_end
|
||||||
|
logger.info(
|
||||||
|
"Preserving trial period during upgrade",
|
||||||
|
extra={
|
||||||
|
"subscription_id": subscription_id,
|
||||||
|
"trial_end": trial_end
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
updated_subscription = stripe.Subscription.modify(
|
||||||
|
subscription_id,
|
||||||
|
**modify_params
|
||||||
)
|
)
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
"Subscription updated",
|
"Subscription updated",
|
||||||
extra={
|
extra={
|
||||||
"subscription_id": subscription_id,
|
"subscription_id": subscription_id,
|
||||||
"new_price_id": new_price_id
|
"new_price_id": new_price_id,
|
||||||
|
"proration_behavior": proration_behavior,
|
||||||
|
"was_trialing": is_trialing,
|
||||||
|
"new_status": updated_subscription.status
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
'subscription_id': updated_subscription.id,
|
'subscription_id': updated_subscription.id,
|
||||||
'status': updated_subscription.status,
|
'status': updated_subscription.status,
|
||||||
'current_period_end': updated_subscription.current_period_end
|
'current_period_start': updated_subscription.current_period_start,
|
||||||
|
'current_period_end': updated_subscription.current_period_end,
|
||||||
|
'trial_end': updated_subscription.trial_end,
|
||||||
|
'is_trialing': updated_subscription.status == 'trialing',
|
||||||
|
'customer_id': updated_subscription.customer
|
||||||
}
|
}
|
||||||
|
|
||||||
except stripe.error.StripeError as e:
|
except stripe.error.StripeError as e:
|
||||||
@@ -1537,6 +1573,13 @@ class StripeClient(PaymentProvider):
|
|||||||
try:
|
try:
|
||||||
invoices = stripe.Invoice.list(customer=customer_id, limit=10)
|
invoices = stripe.Invoice.list(customer=customer_id, limit=10)
|
||||||
|
|
||||||
|
# Debug: Log all invoice IDs and amounts to see what Stripe returns
|
||||||
|
logger.info("stripe_invoices_retrieved",
|
||||||
|
customer_id=customer_id,
|
||||||
|
invoice_count=len(invoices.data),
|
||||||
|
invoice_ids=[inv.id for inv in invoices.data],
|
||||||
|
invoice_amounts=[inv.amount_due for inv in invoices.data])
|
||||||
|
|
||||||
return {
|
return {
|
||||||
'invoices': [
|
'invoices': [
|
||||||
{
|
{
|
||||||
@@ -1545,7 +1588,12 @@ class StripeClient(PaymentProvider):
|
|||||||
'amount_paid': inv.amount_paid / 100,
|
'amount_paid': inv.amount_paid / 100,
|
||||||
'status': inv.status,
|
'status': inv.status,
|
||||||
'created': inv.created,
|
'created': inv.created,
|
||||||
'invoice_pdf': inv.invoice_pdf
|
'invoice_pdf': inv.invoice_pdf,
|
||||||
|
'hosted_invoice_url': inv.hosted_invoice_url,
|
||||||
|
'number': inv.number,
|
||||||
|
'currency': inv.currency,
|
||||||
|
'description': inv.description,
|
||||||
|
'trial': inv.amount_due == 0 # Mark trial invoices
|
||||||
}
|
}
|
||||||
for inv in invoices.data
|
for inv in invoices.data
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -48,6 +48,10 @@ class BaseFastAPIService:
|
|||||||
database_manager: Optional[DatabaseManager] = None,
|
database_manager: Optional[DatabaseManager] = None,
|
||||||
expected_tables: Optional[List[str]] = None,
|
expected_tables: Optional[List[str]] = None,
|
||||||
custom_health_checks: Optional[Dict[str, Callable[[], Any]]] = None,
|
custom_health_checks: Optional[Dict[str, Callable[[], Any]]] = None,
|
||||||
|
redis_enabled: bool = False,
|
||||||
|
redis_url: Optional[str] = None,
|
||||||
|
redis_db: int = 0,
|
||||||
|
redis_max_connections: int = 50,
|
||||||
enable_metrics: bool = True,
|
enable_metrics: bool = True,
|
||||||
enable_health_checks: bool = True,
|
enable_health_checks: bool = True,
|
||||||
enable_cors: bool = True,
|
enable_cors: bool = True,
|
||||||
@@ -80,6 +84,14 @@ class BaseFastAPIService:
|
|||||||
setup_logging(service_name, log_level)
|
setup_logging(service_name, log_level)
|
||||||
self.logger = structlog.get_logger()
|
self.logger = structlog.get_logger()
|
||||||
|
|
||||||
|
# Initialize Redis client if enabled
|
||||||
|
self.redis_enabled = redis_enabled
|
||||||
|
self.redis_client = None
|
||||||
|
self.redis_initialized = False
|
||||||
|
|
||||||
|
if redis_enabled:
|
||||||
|
self._initialize_redis(redis_url, redis_db, redis_max_connections)
|
||||||
|
|
||||||
# Will be set during app creation
|
# Will be set during app creation
|
||||||
self.app: Optional[FastAPI] = None
|
self.app: Optional[FastAPI] = None
|
||||||
self.health_manager = None
|
self.health_manager = None
|
||||||
|
|||||||
Reference in New Issue
Block a user