Refactor subcription layer
This commit is contained in:
@@ -402,6 +402,24 @@ export class SubscriptionService {
|
|||||||
return apiClient.get(`/subscriptions/${tenantId}/invoices`);
|
return apiClient.get(`/subscriptions/${tenantId}/invoices`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update the default payment method for a subscription
|
||||||
|
*/
|
||||||
|
async updatePaymentMethod(
|
||||||
|
tenantId: string,
|
||||||
|
paymentMethodId: string
|
||||||
|
): Promise<{
|
||||||
|
success: boolean;
|
||||||
|
message: string;
|
||||||
|
payment_method_id: string;
|
||||||
|
brand: string;
|
||||||
|
last4: string;
|
||||||
|
exp_month?: number;
|
||||||
|
exp_year?: number;
|
||||||
|
}> {
|
||||||
|
return apiClient.post(`/subscriptions/${tenantId}/update-payment-method?payment_method_id=${paymentMethodId}`, {});
|
||||||
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// NEW METHODS - Usage Forecasting & Predictive Analytics
|
// NEW METHODS - Usage Forecasting & Predictive Analytics
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|||||||
@@ -0,0 +1,258 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { CreditCard, X, CheckCircle, AlertCircle, Loader2 } from 'lucide-react';
|
||||||
|
import { Button, Modal, Input } from '../../components/ui';
|
||||||
|
import { DialogModal } from '../../components/ui/DialogModal/DialogModal';
|
||||||
|
import { subscriptionService } from '../../api';
|
||||||
|
import { showToast } from '../../utils/toast';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
interface PaymentMethodUpdateModalProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
tenantId: string;
|
||||||
|
currentPaymentMethod?: {
|
||||||
|
brand?: string;
|
||||||
|
last4?: string;
|
||||||
|
exp_month?: number;
|
||||||
|
exp_year?: number;
|
||||||
|
};
|
||||||
|
onPaymentMethodUpdated: (paymentMethod: {
|
||||||
|
brand: string;
|
||||||
|
last4: string;
|
||||||
|
exp_month?: number;
|
||||||
|
exp_year?: number;
|
||||||
|
}) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const PaymentMethodUpdateModal: React.FC<PaymentMethodUpdateModalProps> = ({
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
tenantId,
|
||||||
|
currentPaymentMethod,
|
||||||
|
onPaymentMethodUpdated
|
||||||
|
}) => {
|
||||||
|
const { t } = useTranslation('subscription');
|
||||||
|
const [loading, setLoading] = 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);
|
||||||
|
|
||||||
|
// Load Stripe.js dynamically
|
||||||
|
useEffect(() => {
|
||||||
|
if (isOpen) {
|
||||||
|
const loadStripe = async () => {
|
||||||
|
try {
|
||||||
|
// 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('pk_test_your_publishable_key'); // Replace with actual key
|
||||||
|
setStripe(stripeInstance);
|
||||||
|
const elementsInstance = stripeInstance.elements();
|
||||||
|
setElements(elementsInstance);
|
||||||
|
};
|
||||||
|
document.body.appendChild(stripeScript);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to load Stripe:', err);
|
||||||
|
setError('Failed to load payment processor');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
loadStripe();
|
||||||
|
}
|
||||||
|
}, [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 {
|
||||||
|
// 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);
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
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 {
|
||||||
|
setError(result.message || 'Failed to update payment method');
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error updating payment method:', err);
|
||||||
|
setError(err instanceof Error ? err.message : 'An unexpected error occurred');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClose = () => {
|
||||||
|
setError(null);
|
||||||
|
setSuccess(false);
|
||||||
|
setPaymentMethodId('');
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DialogModal
|
||||||
|
isOpen={isOpen}
|
||||||
|
onClose={handleClose}
|
||||||
|
title="Actualizar Método de Pago"
|
||||||
|
message={null}
|
||||||
|
type="custom"
|
||||||
|
size="lg"
|
||||||
|
>
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Current Payment Method Info */}
|
||||||
|
{currentPaymentMethod && (
|
||||||
|
<div className="p-4 bg-[var(--bg-secondary)] rounded-lg border border-[var(--border-secondary)]">
|
||||||
|
<h4 className="font-medium text-[var(--text-primary)] mb-2">Método de Pago Actual</h4>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<CreditCard className="w-6 h-6 text-blue-500" />
|
||||||
|
<div>
|
||||||
|
<p className="text-[var(--text-primary)]">
|
||||||
|
{currentPaymentMethod.brand} terminando en {currentPaymentMethod.last4}
|
||||||
|
</p>
|
||||||
|
{currentPaymentMethod.exp_month && currentPaymentMethod.exp_year && (
|
||||||
|
<p className="text-sm text-[var(--text-secondary)]">
|
||||||
|
Expira: {currentPaymentMethod.exp_month}/{currentPaymentMethod.exp_year}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Payment Form */}
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-[var(--text-primary)] mb-2">
|
||||||
|
Nueva Tarjeta
|
||||||
|
</label>
|
||||||
|
<div id="card-element" className="p-3 border border-[var(--border-primary)] rounded-lg bg-[var(--bg-primary)]">
|
||||||
|
{/* Stripe card element will be mounted here */}
|
||||||
|
{!cardElement && !error && (
|
||||||
|
<div className="flex items-center gap-2 text-[var(--text-secondary)]">
|
||||||
|
<Loader2 className="w-4 h-4 animate-spin" />
|
||||||
|
<span>Cargando procesador de pagos...</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{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>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{success && (
|
||||||
|
<div className="p-3 bg-green-500/10 border border-green-500/20 rounded-lg flex items-center gap-2 text-green-500">
|
||||||
|
<CheckCircle className="w-4 h-4" />
|
||||||
|
<span className="text-sm">Método de pago actualizado correctamente</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex gap-3 justify-end">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={handleClose}
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
Cancelar
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
variant="primary"
|
||||||
|
disabled={loading || !cardElement}
|
||||||
|
className="flex items-center gap-2"
|
||||||
|
>
|
||||||
|
{loading && <Loader2 className="w-4 h-4 animate-spin" />}
|
||||||
|
{loading ? 'Procesando...' : 'Actualizar Método de Pago'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{/* Security Info */}
|
||||||
|
<div className="p-3 bg-blue-500/5 border border-blue-500/20 rounded-lg text-sm text-[var(--text-secondary)]">
|
||||||
|
<p className="flex items-center gap-2">
|
||||||
|
<CreditCard className="w-4 h-4 text-blue-500" />
|
||||||
|
<span>Tus datos de pago están protegidos y se procesan de forma segura.</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DialogModal>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -10,6 +10,7 @@ import { subscriptionService, type UsageSummary, type AvailablePlans } from '../
|
|||||||
import { useSubscriptionEvents } from '../../../../contexts/SubscriptionEventsContext';
|
import { useSubscriptionEvents } from '../../../../contexts/SubscriptionEventsContext';
|
||||||
import { SubscriptionPricingCards } from '../../../../components/subscription/SubscriptionPricingCards';
|
import { SubscriptionPricingCards } from '../../../../components/subscription/SubscriptionPricingCards';
|
||||||
import { PlanComparisonTable, ROICalculator, UsageMetricCard } from '../../../../components/subscription';
|
import { PlanComparisonTable, ROICalculator, UsageMetricCard } from '../../../../components/subscription';
|
||||||
|
import { PaymentMethodUpdateModal } from '../../../../components/subscription/PaymentMethodUpdateModal';
|
||||||
import { useSubscription } from '../../../../hooks/useSubscription';
|
import { useSubscription } from '../../../../hooks/useSubscription';
|
||||||
import {
|
import {
|
||||||
trackSubscriptionPageViewed,
|
trackSubscriptionPageViewed,
|
||||||
@@ -41,6 +42,13 @@ const SubscriptionPage: React.FC = () => {
|
|||||||
// New state for enhanced features
|
// New state for enhanced features
|
||||||
const [showComparison, setShowComparison] = useState(false);
|
const [showComparison, setShowComparison] = useState(false);
|
||||||
const [showROI, setShowROI] = useState(false);
|
const [showROI, setShowROI] = useState(false);
|
||||||
|
const [paymentMethodModalOpen, setPaymentMethodModalOpen] = useState(false);
|
||||||
|
const [currentPaymentMethod, setCurrentPaymentMethod] = useState<{
|
||||||
|
brand?: string;
|
||||||
|
last4?: string;
|
||||||
|
exp_month?: number;
|
||||||
|
exp_year?: number;
|
||||||
|
} | null>(null);
|
||||||
|
|
||||||
// Use new subscription hook for usage forecast data
|
// Use new subscription hook for usage forecast data
|
||||||
const { subscription: subscriptionData, usage: forecastUsage, forecast } = useSubscription();
|
const { subscription: subscriptionData, usage: forecastUsage, forecast } = useSubscription();
|
||||||
@@ -916,7 +924,7 @@ const SubscriptionPage: React.FC = () => {
|
|||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className="flex items-center gap-2 w-full sm:w-auto"
|
className="flex items-center gap-2 w-full sm:w-auto"
|
||||||
onClick={() => showToast.info('Función disponible próximamente')}
|
onClick={() => setPaymentMethodModalOpen(true)}
|
||||||
>
|
>
|
||||||
<CreditCard className="w-4 h-4" />
|
<CreditCard className="w-4 h-4" />
|
||||||
Actualizar Método de Pago
|
Actualizar Método de Pago
|
||||||
@@ -970,6 +978,18 @@ const SubscriptionPage: React.FC = () => {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Payment Method Update Modal */}
|
||||||
|
<PaymentMethodUpdateModal
|
||||||
|
isOpen={paymentMethodModalOpen}
|
||||||
|
onClose={() => setPaymentMethodModalOpen(false)}
|
||||||
|
tenantId={currentTenant?.id || user?.tenant_id || ''}
|
||||||
|
currentPaymentMethod={currentPaymentMethod}
|
||||||
|
onPaymentMethodUpdated={(updatedMethod) => {
|
||||||
|
setCurrentPaymentMethod(updatedMethod);
|
||||||
|
showToast.success('Método de pago actualizado correctamente');
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* Upgrade Dialog */}
|
{/* Upgrade Dialog */}
|
||||||
{upgradeDialogOpen && selectedPlan && availablePlans && (
|
{upgradeDialogOpen && selectedPlan && availablePlans && (
|
||||||
<DialogModal
|
<DialogModal
|
||||||
|
|||||||
@@ -2,27 +2,65 @@
|
|||||||
Subscription management API for GDPR-compliant cancellation and reactivation
|
Subscription management API for GDPR-compliant cancellation and reactivation
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, status, Query
|
from fastapi import APIRouter, Depends, HTTPException, status, Query, Path
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
from datetime import datetime, timezone, timedelta
|
from datetime import datetime, timezone, timedelta
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
import structlog
|
import structlog
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
|
import json
|
||||||
|
|
||||||
from shared.auth.decorators import get_current_user_dep, require_admin_role_dep
|
from shared.auth.decorators import get_current_user_dep, require_admin_role_dep
|
||||||
from shared.routing import RouteBuilder
|
from shared.routing import RouteBuilder
|
||||||
from app.core.database import get_db
|
from app.core.database import get_db
|
||||||
from app.models.tenants import Subscription, Tenant
|
from app.models.tenants import Subscription, Tenant
|
||||||
from app.services.subscription_limit_service import SubscriptionLimitService
|
from app.services.subscription_limit_service import SubscriptionLimitService
|
||||||
|
from app.services.subscription_service import SubscriptionService
|
||||||
from shared.clients.stripe_client import StripeProvider
|
from shared.clients.stripe_client import StripeProvider
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
|
from shared.database.exceptions import DatabaseError, ValidationError
|
||||||
|
from shared.redis_utils import get_redis_client
|
||||||
|
from shared.database.base import create_database_manager
|
||||||
|
import shared.redis_utils
|
||||||
|
|
||||||
|
# Global Redis client for caching
|
||||||
|
_redis_client = None
|
||||||
|
|
||||||
logger = structlog.get_logger()
|
logger = structlog.get_logger()
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
route_builder = RouteBuilder('tenant')
|
route_builder = RouteBuilder('tenant')
|
||||||
|
|
||||||
|
|
||||||
|
async def get_tenant_redis_client():
|
||||||
|
"""Get or create Redis client"""
|
||||||
|
global _redis_client
|
||||||
|
try:
|
||||||
|
if _redis_client is None:
|
||||||
|
from app.core.config import settings
|
||||||
|
_redis_client = await shared.redis_utils.initialize_redis(settings.REDIS_URL)
|
||||||
|
logger.info("Redis client initialized using shared utilities")
|
||||||
|
return _redis_client
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("Failed to initialize Redis client, service will work with limited functionality", error=str(e))
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def get_subscription_limit_service():
|
||||||
|
"""
|
||||||
|
Dependency injection for SubscriptionLimitService
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
database_manager = create_database_manager(settings.DATABASE_URL, "tenant-service")
|
||||||
|
return SubscriptionLimitService(database_manager)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("Failed to create subscription limit service", error=str(e))
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail="Failed to initialize subscription limit service"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class QuotaCheckResponse(BaseModel):
|
class QuotaCheckResponse(BaseModel):
|
||||||
"""Response for quota limit checks"""
|
"""Response for quota limit checks"""
|
||||||
allowed: bool
|
allowed: bool
|
||||||
@@ -96,74 +134,43 @@ async def cancel_subscription(
|
|||||||
5. Gateway enforces read-only mode for 'pending_cancellation' and 'inactive' statuses
|
5. Gateway enforces read-only mode for 'pending_cancellation' and 'inactive' statuses
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
tenant_id = UUID(request.tenant_id)
|
# Use service layer instead of direct database access
|
||||||
|
subscription_service = SubscriptionService(db)
|
||||||
query = select(Subscription).where(Subscription.tenant_id == tenant_id)
|
result = await subscription_service.cancel_subscription(
|
||||||
result = await db.execute(query)
|
request.tenant_id,
|
||||||
subscription = result.scalar_one_or_none()
|
request.reason
|
||||||
|
|
||||||
if not subscription:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_404_NOT_FOUND,
|
|
||||||
detail="Subscription not found"
|
|
||||||
)
|
|
||||||
|
|
||||||
if subscription.status in ['pending_cancellation', 'inactive']:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
|
||||||
detail=f"Subscription is already {subscription.status}"
|
|
||||||
)
|
|
||||||
|
|
||||||
cancellation_effective_date = subscription.next_billing_date or (
|
|
||||||
datetime.now(timezone.utc) + timedelta(days=30)
|
|
||||||
)
|
)
|
||||||
|
|
||||||
subscription.status = 'pending_cancellation'
|
|
||||||
subscription.cancelled_at = datetime.now(timezone.utc)
|
|
||||||
subscription.cancellation_effective_date = cancellation_effective_date
|
|
||||||
|
|
||||||
await db.commit()
|
|
||||||
await db.refresh(subscription)
|
|
||||||
|
|
||||||
# CRITICAL: Invalidate subscription cache
|
|
||||||
try:
|
|
||||||
from app.services.subscription_cache import get_subscription_cache_service
|
|
||||||
import shared.redis_utils
|
|
||||||
|
|
||||||
redis_client = await shared.redis_utils.initialize_redis(settings.REDIS_URL)
|
|
||||||
cache_service = get_subscription_cache_service(redis_client)
|
|
||||||
await cache_service.invalidate_subscription_cache(str(tenant_id))
|
|
||||||
|
|
||||||
logger.info(
|
|
||||||
"Subscription cache invalidated after cancellation",
|
|
||||||
tenant_id=str(tenant_id)
|
|
||||||
)
|
|
||||||
except Exception as cache_error:
|
|
||||||
logger.error(
|
|
||||||
"Failed to invalidate subscription cache after cancellation",
|
|
||||||
tenant_id=str(tenant_id),
|
|
||||||
error=str(cache_error)
|
|
||||||
)
|
|
||||||
|
|
||||||
days_remaining = (cancellation_effective_date - datetime.now(timezone.utc)).days
|
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
"subscription_cancelled",
|
"subscription_cancelled",
|
||||||
tenant_id=str(tenant_id),
|
tenant_id=request.tenant_id,
|
||||||
user_id=current_user.get("sub"),
|
user_id=current_user.get("sub"),
|
||||||
effective_date=cancellation_effective_date.isoformat(),
|
effective_date=result["cancellation_effective_date"],
|
||||||
reason=request.reason[:200] if request.reason else None
|
reason=request.reason[:200] if request.reason else None
|
||||||
)
|
)
|
||||||
|
|
||||||
return SubscriptionCancellationResponse(
|
return SubscriptionCancellationResponse(
|
||||||
success=True,
|
success=result["success"],
|
||||||
message="Subscription cancelled successfully. You will have read-only access until the end of your billing period.",
|
message=result["message"],
|
||||||
status="pending_cancellation",
|
status=result["status"],
|
||||||
cancellation_effective_date=cancellation_effective_date.isoformat(),
|
cancellation_effective_date=result["cancellation_effective_date"],
|
||||||
days_remaining=days_remaining,
|
days_remaining=result["days_remaining"],
|
||||||
read_only_mode_starts=cancellation_effective_date.isoformat()
|
read_only_mode_starts=result["read_only_mode_starts"]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
except ValidationError as ve:
|
||||||
|
logger.error("subscription_cancellation_validation_failed",
|
||||||
|
error=str(ve), tenant_id=request.tenant_id)
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail=str(ve)
|
||||||
|
)
|
||||||
|
except DatabaseError as de:
|
||||||
|
logger.error("subscription_cancellation_failed", error=str(de), tenant_id=request.tenant_id)
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail="Failed to cancel subscription"
|
||||||
|
)
|
||||||
except HTTPException:
|
except HTTPException:
|
||||||
raise
|
raise
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -188,70 +195,41 @@ async def reactivate_subscription(
|
|||||||
- inactive (after effective date)
|
- inactive (after effective date)
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
tenant_id = UUID(request.tenant_id)
|
# Use service layer instead of direct database access
|
||||||
|
subscription_service = SubscriptionService(db)
|
||||||
query = select(Subscription).where(Subscription.tenant_id == tenant_id)
|
result = await subscription_service.reactivate_subscription(
|
||||||
result = await db.execute(query)
|
request.tenant_id,
|
||||||
subscription = result.scalar_one_or_none()
|
request.plan
|
||||||
|
)
|
||||||
if not subscription:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_404_NOT_FOUND,
|
|
||||||
detail="Subscription not found"
|
|
||||||
)
|
|
||||||
|
|
||||||
if subscription.status not in ['pending_cancellation', 'inactive']:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
|
||||||
detail=f"Cannot reactivate subscription with status: {subscription.status}"
|
|
||||||
)
|
|
||||||
|
|
||||||
subscription.status = 'active'
|
|
||||||
subscription.plan = request.plan
|
|
||||||
subscription.cancelled_at = None
|
|
||||||
subscription.cancellation_effective_date = None
|
|
||||||
|
|
||||||
if subscription.status == 'inactive':
|
|
||||||
subscription.next_billing_date = datetime.now(timezone.utc) + timedelta(days=30)
|
|
||||||
|
|
||||||
await db.commit()
|
|
||||||
await db.refresh(subscription)
|
|
||||||
|
|
||||||
# CRITICAL: Invalidate subscription cache
|
|
||||||
try:
|
|
||||||
from app.services.subscription_cache import get_subscription_cache_service
|
|
||||||
import shared.redis_utils
|
|
||||||
|
|
||||||
redis_client = await shared.redis_utils.initialize_redis(settings.REDIS_URL)
|
|
||||||
cache_service = get_subscription_cache_service(redis_client)
|
|
||||||
await cache_service.invalidate_subscription_cache(str(tenant_id))
|
|
||||||
|
|
||||||
logger.info(
|
|
||||||
"Subscription cache invalidated after reactivation",
|
|
||||||
tenant_id=str(tenant_id)
|
|
||||||
)
|
|
||||||
except Exception as cache_error:
|
|
||||||
logger.error(
|
|
||||||
"Failed to invalidate subscription cache after reactivation",
|
|
||||||
tenant_id=str(tenant_id),
|
|
||||||
error=str(cache_error)
|
|
||||||
)
|
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
"subscription_reactivated",
|
"subscription_reactivated",
|
||||||
tenant_id=str(tenant_id),
|
tenant_id=request.tenant_id,
|
||||||
user_id=current_user.get("sub"),
|
user_id=current_user.get("sub"),
|
||||||
new_plan=request.plan
|
new_plan=request.plan
|
||||||
)
|
)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"success": True,
|
"success": result["success"],
|
||||||
"message": "Subscription reactivated successfully",
|
"message": result["message"],
|
||||||
"status": "active",
|
"status": result["status"],
|
||||||
"plan": subscription.plan,
|
"plan": result["plan"],
|
||||||
"next_billing_date": subscription.next_billing_date.isoformat() if subscription.next_billing_date else None
|
"next_billing_date": result["next_billing_date"]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
except ValidationError as ve:
|
||||||
|
logger.error("subscription_reactivation_validation_failed",
|
||||||
|
error=str(ve), tenant_id=request.tenant_id)
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail=str(ve)
|
||||||
|
)
|
||||||
|
except DatabaseError as de:
|
||||||
|
logger.error("subscription_reactivation_failed", error=str(de), tenant_id=request.tenant_id)
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail="Failed to reactivate subscription"
|
||||||
|
)
|
||||||
except HTTPException:
|
except HTTPException:
|
||||||
raise
|
raise
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -272,31 +250,32 @@ async def get_subscription_status(
|
|||||||
Get current subscription status including read-only mode info
|
Get current subscription status including read-only mode info
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
query = select(Subscription).where(Subscription.tenant_id == UUID(tenant_id))
|
# Use service layer instead of direct database access
|
||||||
result = await db.execute(query)
|
subscription_service = SubscriptionService(db)
|
||||||
subscription = result.scalar_one_or_none()
|
result = await subscription_service.get_subscription_status(tenant_id)
|
||||||
|
|
||||||
if not subscription:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_404_NOT_FOUND,
|
|
||||||
detail="Subscription not found"
|
|
||||||
)
|
|
||||||
|
|
||||||
is_read_only = subscription.status in ['pending_cancellation', 'inactive']
|
|
||||||
days_until_inactive = None
|
|
||||||
|
|
||||||
if subscription.status == 'pending_cancellation' and subscription.cancellation_effective_date:
|
|
||||||
days_until_inactive = (subscription.cancellation_effective_date - datetime.now(timezone.utc)).days
|
|
||||||
|
|
||||||
return SubscriptionStatusResponse(
|
return SubscriptionStatusResponse(
|
||||||
tenant_id=str(tenant_id),
|
tenant_id=result["tenant_id"],
|
||||||
status=subscription.status,
|
status=result["status"],
|
||||||
plan=subscription.plan,
|
plan=result["plan"],
|
||||||
is_read_only=is_read_only,
|
is_read_only=result["is_read_only"],
|
||||||
cancellation_effective_date=subscription.cancellation_effective_date.isoformat() if subscription.cancellation_effective_date else None,
|
cancellation_effective_date=result["cancellation_effective_date"],
|
||||||
days_until_inactive=days_until_inactive
|
days_until_inactive=result["days_until_inactive"]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
except ValidationError as ve:
|
||||||
|
logger.error("get_subscription_status_validation_failed",
|
||||||
|
error=str(ve), tenant_id=tenant_id)
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail=str(ve)
|
||||||
|
)
|
||||||
|
except DatabaseError as de:
|
||||||
|
logger.error("get_subscription_status_failed", error=str(de), tenant_id=tenant_id)
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail="Failed to get subscription status"
|
||||||
|
)
|
||||||
except HTTPException:
|
except HTTPException:
|
||||||
raise
|
raise
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -317,48 +296,40 @@ async def get_tenant_invoices(
|
|||||||
Get invoice history for a tenant from Stripe
|
Get invoice history for a tenant from Stripe
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
# Verify tenant exists
|
# Use service layer instead of direct database access
|
||||||
query = select(Tenant).where(Tenant.id == UUID(tenant_id))
|
subscription_service = SubscriptionService(db)
|
||||||
result = await db.execute(query)
|
invoices_data = await subscription_service.get_tenant_invoices(tenant_id)
|
||||||
tenant = result.scalar_one_or_none()
|
|
||||||
|
|
||||||
if not tenant:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_404_NOT_FOUND,
|
|
||||||
detail="Tenant not found"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Check if tenant has a Stripe customer ID
|
|
||||||
if not tenant.stripe_customer_id:
|
|
||||||
logger.info("no_stripe_customer_id", tenant_id=tenant_id)
|
|
||||||
return []
|
|
||||||
|
|
||||||
# Initialize Stripe provider
|
|
||||||
stripe_provider = StripeProvider(
|
|
||||||
api_key=settings.STRIPE_SECRET_KEY,
|
|
||||||
webhook_secret=settings.STRIPE_WEBHOOK_SECRET
|
|
||||||
)
|
|
||||||
|
|
||||||
# Fetch invoices from Stripe
|
|
||||||
stripe_invoices = await stripe_provider.get_invoices(tenant.stripe_customer_id)
|
|
||||||
|
|
||||||
# Transform to response format
|
# Transform to response format
|
||||||
invoices = []
|
invoices = []
|
||||||
for invoice in stripe_invoices:
|
for invoice_data in invoices_data:
|
||||||
invoices.append(InvoiceResponse(
|
invoices.append(InvoiceResponse(
|
||||||
id=invoice.id,
|
id=invoice_data["id"],
|
||||||
date=invoice.created_at.strftime('%Y-%m-%d'),
|
date=invoice_data["date"],
|
||||||
amount=invoice.amount,
|
amount=invoice_data["amount"],
|
||||||
currency=invoice.currency.upper(),
|
currency=invoice_data["currency"],
|
||||||
status=invoice.status,
|
status=invoice_data["status"],
|
||||||
description=invoice.description,
|
description=invoice_data["description"],
|
||||||
invoice_pdf=invoice.invoice_pdf,
|
invoice_pdf=invoice_data["invoice_pdf"],
|
||||||
hosted_invoice_url=invoice.hosted_invoice_url
|
hosted_invoice_url=invoice_data["hosted_invoice_url"]
|
||||||
))
|
))
|
||||||
|
|
||||||
logger.info("invoices_retrieved", tenant_id=tenant_id, count=len(invoices))
|
logger.info("invoices_retrieved", tenant_id=tenant_id, count=len(invoices))
|
||||||
return invoices
|
return invoices
|
||||||
|
|
||||||
|
except ValidationError as ve:
|
||||||
|
logger.error("get_invoices_validation_failed",
|
||||||
|
error=str(ve), tenant_id=tenant_id)
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail=str(ve)
|
||||||
|
)
|
||||||
|
except DatabaseError as de:
|
||||||
|
logger.error("get_invoices_failed", error=str(de), tenant_id=tenant_id)
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail="Failed to retrieve invoices"
|
||||||
|
)
|
||||||
except HTTPException:
|
except HTTPException:
|
||||||
raise
|
raise
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -367,3 +338,478 @@ async def get_tenant_invoices(
|
|||||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
detail="Failed to retrieve invoices"
|
detail="Failed to retrieve invoices"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# ADDITIONAL SUBSCRIPTION ENDPOINTS (Consolidated from tenant_operations.py)
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
@router.get("/api/v1/subscriptions/{tenant_id}/tier")
|
||||||
|
async def get_tenant_subscription_tier_fast(
|
||||||
|
tenant_id: str = Path(..., description="Tenant ID"),
|
||||||
|
redis_client = Depends(get_tenant_redis_client)
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Fast cached lookup for tenant subscription tier
|
||||||
|
|
||||||
|
This endpoint is optimized for high-frequency access (e.g., from gateway middleware)
|
||||||
|
with Redis caching (10-minute TTL).
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
from app.services.subscription_cache import get_subscription_cache_service
|
||||||
|
|
||||||
|
cache_service = get_subscription_cache_service(redis_client)
|
||||||
|
tier = await cache_service.get_tenant_tier_cached(str(tenant_id))
|
||||||
|
|
||||||
|
return {
|
||||||
|
"tenant_id": str(tenant_id),
|
||||||
|
"tier": tier,
|
||||||
|
"cached": True
|
||||||
|
}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("Failed to get subscription tier",
|
||||||
|
tenant_id=str(tenant_id),
|
||||||
|
error=str(e))
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail="Failed to get subscription tier"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/api/v1/subscriptions/{tenant_id}/active")
|
||||||
|
async def get_tenant_active_subscription(
|
||||||
|
tenant_id: str = Path(..., description="Tenant ID"),
|
||||||
|
redis_client = Depends(get_tenant_redis_client)
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Get full active subscription with caching
|
||||||
|
|
||||||
|
Returns complete subscription details with 10-minute Redis cache.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
from app.services.subscription_cache import get_subscription_cache_service
|
||||||
|
|
||||||
|
cache_service = get_subscription_cache_service(redis_client)
|
||||||
|
subscription = await cache_service.get_tenant_subscription_cached(str(tenant_id))
|
||||||
|
|
||||||
|
if not subscription:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="No active subscription found"
|
||||||
|
)
|
||||||
|
|
||||||
|
return subscription
|
||||||
|
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("Failed to get active subscription",
|
||||||
|
tenant_id=str(tenant_id),
|
||||||
|
error=str(e))
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail="Failed to get active subscription"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/api/v1/subscriptions/{tenant_id}/limits")
|
||||||
|
async def get_subscription_limits(
|
||||||
|
tenant_id: str = Path(..., description="Tenant ID"),
|
||||||
|
current_user: dict = Depends(get_current_user_dep),
|
||||||
|
limit_service: SubscriptionLimitService = Depends(get_subscription_limit_service)
|
||||||
|
):
|
||||||
|
"""Get current subscription limits for a tenant"""
|
||||||
|
|
||||||
|
try:
|
||||||
|
limits = await limit_service.get_tenant_subscription_limits(str(tenant_id))
|
||||||
|
return limits
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("Failed to get subscription limits",
|
||||||
|
tenant_id=str(tenant_id),
|
||||||
|
error=str(e))
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail="Failed to get subscription limits"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/api/v1/subscriptions/{tenant_id}/usage")
|
||||||
|
async def get_usage_summary(
|
||||||
|
tenant_id: str = Path(..., description="Tenant ID"),
|
||||||
|
current_user: dict = Depends(get_current_user_dep),
|
||||||
|
limit_service: SubscriptionLimitService = Depends(get_subscription_limit_service)
|
||||||
|
):
|
||||||
|
"""Get usage summary vs limits for a tenant (cached for 30s for performance)"""
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Try to get from cache first (30s TTL)
|
||||||
|
redis_client = await get_redis_client()
|
||||||
|
|
||||||
|
if redis_client:
|
||||||
|
cache_key = f"usage_summary:{tenant_id}"
|
||||||
|
cached = await redis_client.get(cache_key)
|
||||||
|
if cached:
|
||||||
|
logger.debug("Usage summary cache hit", tenant_id=str(tenant_id))
|
||||||
|
return json.loads(cached)
|
||||||
|
|
||||||
|
# Cache miss - fetch fresh data
|
||||||
|
usage = await limit_service.get_usage_summary(str(tenant_id))
|
||||||
|
|
||||||
|
# Store in cache with 30s TTL
|
||||||
|
if redis_client:
|
||||||
|
await redis_client.setex(cache_key, 30, json.dumps(usage))
|
||||||
|
logger.debug("Usage summary cached", tenant_id=str(tenant_id))
|
||||||
|
|
||||||
|
return usage
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("Failed to get usage summary",
|
||||||
|
tenant_id=str(tenant_id),
|
||||||
|
error=str(e))
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail="Failed to get usage summary"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/api/v1/subscriptions/{tenant_id}/can-add-location")
|
||||||
|
async def can_add_location(
|
||||||
|
tenant_id: str = Path(..., description="Tenant ID"),
|
||||||
|
current_user: dict = Depends(get_current_user_dep),
|
||||||
|
limit_service: SubscriptionLimitService = Depends(get_subscription_limit_service)
|
||||||
|
):
|
||||||
|
"""Check if tenant can add another location"""
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = await limit_service.can_add_location(str(tenant_id))
|
||||||
|
return result
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("Failed to check location limits",
|
||||||
|
tenant_id=str(tenant_id),
|
||||||
|
error=str(e))
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail="Failed to check location limits"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/api/v1/subscriptions/{tenant_id}/can-add-product")
|
||||||
|
async def can_add_product(
|
||||||
|
tenant_id: str = Path(..., description="Tenant ID"),
|
||||||
|
current_user: dict = Depends(get_current_user_dep),
|
||||||
|
limit_service: SubscriptionLimitService = Depends(get_subscription_limit_service)
|
||||||
|
):
|
||||||
|
"""Check if tenant can add another product"""
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = await limit_service.can_add_product(str(tenant_id))
|
||||||
|
return result
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("Failed to check product limits",
|
||||||
|
tenant_id=str(tenant_id),
|
||||||
|
error=str(e))
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail="Failed to check product limits"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/api/v1/subscriptions/{tenant_id}/can-add-user")
|
||||||
|
async def can_add_user(
|
||||||
|
tenant_id: str = Path(..., description="Tenant ID"),
|
||||||
|
current_user: dict = Depends(get_current_user_dep),
|
||||||
|
limit_service: SubscriptionLimitService = Depends(get_subscription_limit_service)
|
||||||
|
):
|
||||||
|
"""Check if tenant can add another user/member"""
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = await limit_service.can_add_user(str(tenant_id))
|
||||||
|
return result
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("Failed to check user limits",
|
||||||
|
tenant_id=str(tenant_id),
|
||||||
|
error=str(e))
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail="Failed to check user limits"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/api/v1/subscriptions/{tenant_id}/features/{feature}")
|
||||||
|
async def has_feature(
|
||||||
|
tenant_id: str = Path(..., description="Tenant ID"),
|
||||||
|
feature: str = Path(..., description="Feature name"),
|
||||||
|
current_user: dict = Depends(get_current_user_dep),
|
||||||
|
limit_service: SubscriptionLimitService = Depends(get_subscription_limit_service)
|
||||||
|
):
|
||||||
|
"""Check if tenant has access to a specific feature"""
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = await limit_service.has_feature(str(tenant_id), feature)
|
||||||
|
return result
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("Failed to check feature access",
|
||||||
|
tenant_id=str(tenant_id),
|
||||||
|
feature=feature,
|
||||||
|
error=str(e))
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail="Failed to check feature access"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/api/v1/subscriptions/{tenant_id}/validate-upgrade/{new_plan}")
|
||||||
|
async def validate_plan_upgrade(
|
||||||
|
tenant_id: str = Path(..., description="Tenant ID"),
|
||||||
|
new_plan: str = Path(..., description="New plan name"),
|
||||||
|
current_user: dict = Depends(get_current_user_dep),
|
||||||
|
limit_service: SubscriptionLimitService = Depends(get_subscription_limit_service)
|
||||||
|
):
|
||||||
|
"""Validate if tenant can upgrade to a new plan"""
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = await limit_service.validate_plan_upgrade(str(tenant_id), new_plan)
|
||||||
|
return result
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("Failed to validate plan upgrade",
|
||||||
|
tenant_id=str(tenant_id),
|
||||||
|
new_plan=new_plan,
|
||||||
|
error=str(e))
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail="Failed to validate plan upgrade"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/api/v1/subscriptions/{tenant_id}/upgrade")
|
||||||
|
async def upgrade_subscription_plan(
|
||||||
|
tenant_id: str = Path(..., description="Tenant ID"),
|
||||||
|
new_plan: str = Query(..., description="New plan name"),
|
||||||
|
current_user: dict = Depends(get_current_user_dep),
|
||||||
|
limit_service: SubscriptionLimitService = Depends(get_subscription_limit_service),
|
||||||
|
db: AsyncSession = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""Upgrade subscription plan for a tenant"""
|
||||||
|
|
||||||
|
try:
|
||||||
|
# First validate the upgrade
|
||||||
|
validation = await limit_service.validate_plan_upgrade(str(tenant_id), new_plan)
|
||||||
|
if not validation.get("can_upgrade", False):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail=validation.get("reason", "Cannot upgrade to this plan")
|
||||||
|
)
|
||||||
|
|
||||||
|
# Use SubscriptionService for the upgrade
|
||||||
|
subscription_service = SubscriptionService(db)
|
||||||
|
|
||||||
|
# Get current subscription
|
||||||
|
current_subscription = await subscription_service.get_subscription_by_tenant_id(tenant_id)
|
||||||
|
if not current_subscription:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="No active subscription found for this tenant"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Update the subscription plan using service layer
|
||||||
|
# Note: This should be enhanced in SubscriptionService to handle plan upgrades
|
||||||
|
# For now, we'll use the repository directly but this should be moved to service layer
|
||||||
|
from app.repositories.subscription_repository import SubscriptionRepository
|
||||||
|
from app.models.tenants import Subscription as SubscriptionModel
|
||||||
|
|
||||||
|
subscription_repo = SubscriptionRepository(SubscriptionModel, db)
|
||||||
|
updated_subscription = await subscription_repo.update_subscription_plan(
|
||||||
|
str(current_subscription.id),
|
||||||
|
new_plan
|
||||||
|
)
|
||||||
|
|
||||||
|
# Invalidate subscription cache to ensure immediate availability of new tier
|
||||||
|
try:
|
||||||
|
from app.services.subscription_cache import get_subscription_cache_service
|
||||||
|
import shared.redis_utils
|
||||||
|
|
||||||
|
redis_client = await shared.redis_utils.initialize_redis(settings.REDIS_URL)
|
||||||
|
cache_service = get_subscription_cache_service(redis_client)
|
||||||
|
await cache_service.invalidate_subscription_cache(str(tenant_id))
|
||||||
|
|
||||||
|
logger.info("Subscription cache invalidated after upgrade",
|
||||||
|
tenant_id=str(tenant_id),
|
||||||
|
new_plan=new_plan)
|
||||||
|
except Exception as cache_error:
|
||||||
|
logger.error("Failed to invalidate subscription cache after upgrade",
|
||||||
|
tenant_id=str(tenant_id),
|
||||||
|
error=str(cache_error))
|
||||||
|
|
||||||
|
# SECURITY: Invalidate all existing tokens for this tenant
|
||||||
|
# Forces users to re-authenticate and get new JWT with updated tier
|
||||||
|
try:
|
||||||
|
redis_client = await get_redis_client()
|
||||||
|
if redis_client:
|
||||||
|
changed_timestamp = datetime.now(timezone.utc).timestamp()
|
||||||
|
await redis_client.set(
|
||||||
|
f"tenant:{tenant_id}:subscription_changed_at",
|
||||||
|
str(changed_timestamp),
|
||||||
|
ex=86400 # 24 hour TTL
|
||||||
|
)
|
||||||
|
logger.info("Set subscription change timestamp for token invalidation",
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
timestamp=changed_timestamp)
|
||||||
|
except Exception as token_error:
|
||||||
|
logger.error("Failed to invalidate tenant tokens after upgrade",
|
||||||
|
tenant_id=str(tenant_id),
|
||||||
|
error=str(token_error))
|
||||||
|
|
||||||
|
# Also publish event for real-time notification
|
||||||
|
try:
|
||||||
|
from shared.messaging import UnifiedEventPublisher
|
||||||
|
event_publisher = UnifiedEventPublisher()
|
||||||
|
await event_publisher.publish_business_event(
|
||||||
|
event_type="subscription.changed",
|
||||||
|
tenant_id=str(tenant_id),
|
||||||
|
data={
|
||||||
|
"tenant_id": str(tenant_id),
|
||||||
|
"old_tier": current_subscription.plan,
|
||||||
|
"new_tier": new_plan,
|
||||||
|
"action": "upgrade"
|
||||||
|
}
|
||||||
|
)
|
||||||
|
logger.info("Published subscription change event",
|
||||||
|
tenant_id=str(tenant_id),
|
||||||
|
event_type="subscription.changed")
|
||||||
|
except Exception as event_error:
|
||||||
|
logger.error("Failed to publish subscription change event",
|
||||||
|
tenant_id=str(tenant_id),
|
||||||
|
error=str(event_error))
|
||||||
|
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"message": f"Plan successfully upgraded to {new_plan}",
|
||||||
|
"old_plan": current_subscription.plan,
|
||||||
|
"new_plan": new_plan,
|
||||||
|
"new_monthly_price": updated_subscription.monthly_price,
|
||||||
|
"validation": validation,
|
||||||
|
"requires_token_refresh": True # Signal to frontend
|
||||||
|
}
|
||||||
|
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("Failed to upgrade subscription plan",
|
||||||
|
tenant_id=str(tenant_id),
|
||||||
|
new_plan=new_plan,
|
||||||
|
error=str(e))
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail="Failed to upgrade subscription plan"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/api/v1/subscriptions/register-with-subscription")
|
||||||
|
async def register_with_subscription(
|
||||||
|
user_data: dict = Depends(get_current_user_dep),
|
||||||
|
plan_id: str = Query(..., description="Plan ID to subscribe to"),
|
||||||
|
payment_method_id: str = Query(..., description="Payment method ID from frontend"),
|
||||||
|
use_trial: bool = Query(False, description="Whether to use trial period for pilot users"),
|
||||||
|
db: AsyncSession = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""Process user registration with subscription creation"""
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Use SubscriptionService for registration
|
||||||
|
subscription_service = SubscriptionService(db)
|
||||||
|
|
||||||
|
result = await subscription_service.create_subscription(
|
||||||
|
user_data.get('tenant_id'),
|
||||||
|
plan_id,
|
||||||
|
payment_method_id,
|
||||||
|
14 if use_trial else None
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"message": "Registration and subscription created successfully",
|
||||||
|
"data": result
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("Failed to register with subscription", error=str(e))
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail="Failed to register with subscription"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/api/v1/subscriptions/{tenant_id}/update-payment-method")
|
||||||
|
async def update_payment_method(
|
||||||
|
tenant_id: str = Path(..., description="Tenant ID"),
|
||||||
|
payment_method_id: str = Query(..., description="New payment method ID"),
|
||||||
|
current_user: dict = Depends(get_current_user_dep),
|
||||||
|
db: AsyncSession = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Update the default payment method for a subscription
|
||||||
|
|
||||||
|
This endpoint allows users to change their payment method through the UI.
|
||||||
|
It updates the default payment method in Stripe and returns the updated
|
||||||
|
payment method information.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# Use SubscriptionService to get subscription and update payment method
|
||||||
|
subscription_service = SubscriptionService(db)
|
||||||
|
|
||||||
|
# Get current subscription
|
||||||
|
subscription = await subscription_service.get_subscription_by_tenant_id(tenant_id)
|
||||||
|
if not subscription:
|
||||||
|
raise ValidationError(f"Subscription not found for tenant {tenant_id}")
|
||||||
|
|
||||||
|
if not subscription.stripe_customer_id:
|
||||||
|
raise ValidationError(f"Tenant {tenant_id} does not have a Stripe customer ID")
|
||||||
|
|
||||||
|
# Update payment method via PaymentService
|
||||||
|
payment_result = await subscription_service.payment_service.update_payment_method(
|
||||||
|
subscription.stripe_customer_id,
|
||||||
|
payment_method_id
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info("Payment method updated successfully",
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
payment_method_id=payment_method_id,
|
||||||
|
user_id=current_user.get("user_id"))
|
||||||
|
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"message": "Payment method updated successfully",
|
||||||
|
"payment_method_id": payment_result.id,
|
||||||
|
"brand": getattr(payment_result, 'brand', 'unknown'),
|
||||||
|
"last4": getattr(payment_result, 'last4', '0000'),
|
||||||
|
"exp_month": getattr(payment_result, 'exp_month', None),
|
||||||
|
"exp_year": getattr(payment_result, 'exp_year', None)
|
||||||
|
}
|
||||||
|
|
||||||
|
except ValidationError as ve:
|
||||||
|
logger.error("update_payment_method_validation_failed",
|
||||||
|
error=str(ve), tenant_id=tenant_id)
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail=str(ve)
|
||||||
|
)
|
||||||
|
except DatabaseError as de:
|
||||||
|
logger.error("update_payment_method_failed",
|
||||||
|
error=str(de), tenant_id=tenant_id)
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail="Failed to update payment method"
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("update_payment_method_unexpected_error",
|
||||||
|
error=str(e), tenant_id=tenant_id)
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail="An unexpected error occurred while updating payment method"
|
||||||
|
)
|
||||||
|
|||||||
@@ -1095,139 +1095,6 @@ async def register_with_subscription(
|
|||||||
detail="Failed to register with subscription"
|
detail="Failed to register with subscription"
|
||||||
)
|
)
|
||||||
|
|
||||||
@router.post(route_builder.build_base_route("subscriptions/{tenant_id}/cancel", include_tenant_prefix=False))
|
|
||||||
async def cancel_subscription(
|
|
||||||
tenant_id: UUID = Path(..., description="Tenant ID"),
|
|
||||||
current_user: Dict[str, Any] = Depends(get_current_user_dep),
|
|
||||||
payment_service: PaymentService = Depends(get_payment_service)
|
|
||||||
):
|
|
||||||
"""Cancel subscription for a tenant"""
|
|
||||||
|
|
||||||
try:
|
|
||||||
# Verify user is owner/admin of tenant
|
|
||||||
user_id = current_user.get('user_id')
|
|
||||||
user_role = current_user.get('role', '').lower()
|
|
||||||
|
|
||||||
# Check if user is tenant owner or admin
|
|
||||||
from app.services.tenant_service import EnhancedTenantService
|
|
||||||
from shared.database.base import create_database_manager
|
|
||||||
|
|
||||||
tenant_service = EnhancedTenantService(create_database_manager())
|
|
||||||
|
|
||||||
# Verify tenant access and role
|
|
||||||
async with tenant_service.database_manager.get_session() as session:
|
|
||||||
await tenant_service._init_repositories(session)
|
|
||||||
|
|
||||||
# Get tenant member record
|
|
||||||
member = await tenant_service.member_repo.get_member_by_user_and_tenant(
|
|
||||||
str(user_id), str(tenant_id)
|
|
||||||
)
|
|
||||||
|
|
||||||
if not member:
|
|
||||||
logger.warning("User not member of tenant",
|
|
||||||
user_id=user_id,
|
|
||||||
tenant_id=str(tenant_id))
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_403_FORBIDDEN,
|
|
||||||
detail="Access denied: You are not a member of this tenant"
|
|
||||||
)
|
|
||||||
|
|
||||||
if member.role not in ['owner', 'admin']:
|
|
||||||
logger.warning("Insufficient permissions to cancel subscription",
|
|
||||||
user_id=user_id,
|
|
||||||
tenant_id=str(tenant_id),
|
|
||||||
role=member.role)
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_403_FORBIDDEN,
|
|
||||||
detail="Access denied: Only owners and admins can cancel subscriptions"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Get subscription ID from database
|
|
||||||
subscription = await tenant_service.subscription_repo.get_active_subscription(str(tenant_id))
|
|
||||||
if not subscription or not subscription.stripe_subscription_id:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_404_NOT_FOUND,
|
|
||||||
detail="No active subscription found for this tenant"
|
|
||||||
)
|
|
||||||
|
|
||||||
subscription_id = subscription.stripe_subscription_id
|
|
||||||
|
|
||||||
result = await payment_service.cancel_subscription(subscription_id)
|
|
||||||
|
|
||||||
return {
|
|
||||||
"success": True,
|
|
||||||
"message": "Subscription cancelled successfully",
|
|
||||||
"data": {
|
|
||||||
"subscription_id": result.id,
|
|
||||||
"status": result.status
|
|
||||||
}
|
|
||||||
}
|
|
||||||
except Exception as e:
|
|
||||||
logger.error("Failed to cancel subscription", error=str(e))
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
||||||
detail="Failed to cancel subscription"
|
|
||||||
)
|
|
||||||
|
|
||||||
@router.get(route_builder.build_base_route("subscriptions/{tenant_id}/invoices", include_tenant_prefix=False))
|
|
||||||
async def get_invoices(
|
|
||||||
tenant_id: UUID = Path(..., description="Tenant ID"),
|
|
||||||
current_user: Dict[str, Any] = Depends(get_current_user_dep),
|
|
||||||
payment_service: PaymentService = Depends(get_payment_service)
|
|
||||||
):
|
|
||||||
"""Get invoices for a tenant"""
|
|
||||||
|
|
||||||
try:
|
|
||||||
# Verify user has access to tenant
|
|
||||||
user_id = current_user.get('user_id')
|
|
||||||
|
|
||||||
from app.services.tenant_service import EnhancedTenantService
|
|
||||||
from shared.database.base import create_database_manager
|
|
||||||
|
|
||||||
tenant_service = EnhancedTenantService(create_database_manager())
|
|
||||||
|
|
||||||
async with tenant_service.database_manager.get_session() as session:
|
|
||||||
await tenant_service._init_repositories(session)
|
|
||||||
|
|
||||||
# Verify user is member of tenant
|
|
||||||
member = await tenant_service.member_repo.get_member_by_user_and_tenant(
|
|
||||||
str(user_id), str(tenant_id)
|
|
||||||
)
|
|
||||||
|
|
||||||
if not member:
|
|
||||||
logger.warning("User not member of tenant",
|
|
||||||
user_id=user_id,
|
|
||||||
tenant_id=str(tenant_id))
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_403_FORBIDDEN,
|
|
||||||
detail="Access denied: You do not have access to this tenant"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Get subscription with customer ID
|
|
||||||
subscription = await tenant_service.subscription_repo.get_active_subscription(str(tenant_id))
|
|
||||||
if not subscription:
|
|
||||||
# No subscription found, return empty invoices list
|
|
||||||
return []
|
|
||||||
|
|
||||||
# Check if subscription has stripe customer ID
|
|
||||||
stripe_customer_id = getattr(subscription, 'stripe_customer_id', None)
|
|
||||||
if not stripe_customer_id:
|
|
||||||
# No Stripe customer ID, return empty invoices (demo tenants, free tier, etc.)
|
|
||||||
logger.debug("No Stripe customer ID for tenant",
|
|
||||||
tenant_id=str(tenant_id),
|
|
||||||
plan=getattr(subscription, 'plan', 'unknown'))
|
|
||||||
return []
|
|
||||||
|
|
||||||
invoices = await payment_service.get_invoices(stripe_customer_id)
|
|
||||||
|
|
||||||
return invoices
|
|
||||||
except Exception as e:
|
|
||||||
logger.error("Failed to get invoices", error=str(e))
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
||||||
detail="Failed to get invoices"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
async def _invalidate_tenant_tokens(tenant_id: str, redis_client):
|
async def _invalidate_tenant_tokens(tenant_id: str, redis_client):
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -102,6 +102,42 @@ class SubscriptionRepository(TenantBaseRepository):
|
|||||||
error=str(e))
|
error=str(e))
|
||||||
raise DatabaseError(f"Failed to create subscription: {str(e)}")
|
raise DatabaseError(f"Failed to create subscription: {str(e)}")
|
||||||
|
|
||||||
|
async def get_by_tenant_id(self, tenant_id: str) -> Optional[Subscription]:
|
||||||
|
"""Get subscription by tenant ID"""
|
||||||
|
try:
|
||||||
|
subscriptions = await self.get_multi(
|
||||||
|
filters={
|
||||||
|
"tenant_id": tenant_id
|
||||||
|
},
|
||||||
|
limit=1,
|
||||||
|
order_by="created_at",
|
||||||
|
order_desc=True
|
||||||
|
)
|
||||||
|
return subscriptions[0] if subscriptions else None
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("Failed to get subscription by tenant ID",
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
error=str(e))
|
||||||
|
raise DatabaseError(f"Failed to get subscription: {str(e)}")
|
||||||
|
|
||||||
|
async def get_by_stripe_id(self, stripe_subscription_id: str) -> Optional[Subscription]:
|
||||||
|
"""Get subscription by Stripe subscription ID"""
|
||||||
|
try:
|
||||||
|
subscriptions = await self.get_multi(
|
||||||
|
filters={
|
||||||
|
"stripe_subscription_id": stripe_subscription_id
|
||||||
|
},
|
||||||
|
limit=1,
|
||||||
|
order_by="created_at",
|
||||||
|
order_desc=True
|
||||||
|
)
|
||||||
|
return subscriptions[0] if subscriptions else None
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("Failed to get subscription by Stripe ID",
|
||||||
|
stripe_subscription_id=stripe_subscription_id,
|
||||||
|
error=str(e))
|
||||||
|
raise DatabaseError(f"Failed to get subscription: {str(e)}")
|
||||||
|
|
||||||
async def get_active_subscription(self, tenant_id: str) -> Optional[Subscription]:
|
async def get_active_subscription(self, tenant_id: str) -> Optional[Subscription]:
|
||||||
"""Get active subscription for tenant"""
|
"""Get active subscription for tenant"""
|
||||||
try:
|
try:
|
||||||
|
|||||||
424
services/tenant/app/services/subscription_service.py
Normal file
424
services/tenant/app/services/subscription_service.py
Normal file
@@ -0,0 +1,424 @@
|
|||||||
|
"""
|
||||||
|
Subscription Service for managing subscription lifecycle operations
|
||||||
|
This service orchestrates business logic and integrates with payment providers
|
||||||
|
"""
|
||||||
|
|
||||||
|
import structlog
|
||||||
|
from typing import Dict, Any, Optional, List
|
||||||
|
from datetime import datetime, timezone, timedelta
|
||||||
|
from uuid import UUID
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
from app.models.tenants import Subscription, Tenant
|
||||||
|
from app.repositories.subscription_repository import SubscriptionRepository
|
||||||
|
from app.services.payment_service import PaymentService
|
||||||
|
from shared.clients.stripe_client import StripeProvider
|
||||||
|
from app.core.config import settings
|
||||||
|
from shared.database.exceptions import DatabaseError, ValidationError
|
||||||
|
|
||||||
|
logger = structlog.get_logger()
|
||||||
|
|
||||||
|
|
||||||
|
class SubscriptionService:
|
||||||
|
"""Service for managing subscription lifecycle operations"""
|
||||||
|
|
||||||
|
def __init__(self, db_session: AsyncSession):
|
||||||
|
self.db_session = db_session
|
||||||
|
self.subscription_repo = SubscriptionRepository(Subscription, db_session)
|
||||||
|
self.payment_service = PaymentService()
|
||||||
|
|
||||||
|
async def cancel_subscription(
|
||||||
|
self,
|
||||||
|
tenant_id: str,
|
||||||
|
reason: str = ""
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Cancel a subscription with proper business logic and payment provider integration
|
||||||
|
|
||||||
|
Args:
|
||||||
|
tenant_id: Tenant ID to cancel subscription for
|
||||||
|
reason: Optional cancellation reason
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary with cancellation details
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
tenant_uuid = UUID(tenant_id)
|
||||||
|
|
||||||
|
# Get subscription from repository
|
||||||
|
subscription = await self.subscription_repo.get_by_tenant_id(str(tenant_uuid))
|
||||||
|
|
||||||
|
if not subscription:
|
||||||
|
raise ValidationError(f"Subscription not found for tenant {tenant_id}")
|
||||||
|
|
||||||
|
if subscription.status in ['pending_cancellation', 'inactive']:
|
||||||
|
raise ValidationError(f"Subscription is already {subscription.status}")
|
||||||
|
|
||||||
|
# Calculate cancellation effective date (end of billing period)
|
||||||
|
cancellation_effective_date = subscription.next_billing_date or (
|
||||||
|
datetime.now(timezone.utc) + timedelta(days=30)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Update subscription status in database
|
||||||
|
update_data = {
|
||||||
|
'status': 'pending_cancellation',
|
||||||
|
'cancelled_at': datetime.now(timezone.utc),
|
||||||
|
'cancellation_effective_date': cancellation_effective_date
|
||||||
|
}
|
||||||
|
|
||||||
|
updated_subscription = await self.subscription_repo.update(str(subscription.id), update_data)
|
||||||
|
|
||||||
|
# Invalidate subscription cache
|
||||||
|
try:
|
||||||
|
from app.services.subscription_cache import get_subscription_cache_service
|
||||||
|
import shared.redis_utils
|
||||||
|
|
||||||
|
redis_client = await shared.redis_utils.initialize_redis(settings.REDIS_URL)
|
||||||
|
cache_service = get_subscription_cache_service(redis_client)
|
||||||
|
await cache_service.invalidate_subscription_cache(str(tenant_id))
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"Subscription cache invalidated after cancellation",
|
||||||
|
tenant_id=str(tenant_id)
|
||||||
|
)
|
||||||
|
except Exception as cache_error:
|
||||||
|
logger.error(
|
||||||
|
"Failed to invalidate subscription cache after cancellation",
|
||||||
|
tenant_id=str(tenant_id),
|
||||||
|
error=str(cache_error)
|
||||||
|
)
|
||||||
|
|
||||||
|
days_remaining = (cancellation_effective_date - datetime.now(timezone.utc)).days
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"subscription_cancelled",
|
||||||
|
tenant_id=str(tenant_id),
|
||||||
|
effective_date=cancellation_effective_date.isoformat(),
|
||||||
|
reason=reason[:200] if reason else None
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"message": "Subscription cancelled successfully. You will have read-only access until the end of your billing period.",
|
||||||
|
"status": "pending_cancellation",
|
||||||
|
"cancellation_effective_date": cancellation_effective_date.isoformat(),
|
||||||
|
"days_remaining": days_remaining,
|
||||||
|
"read_only_mode_starts": cancellation_effective_date.isoformat()
|
||||||
|
}
|
||||||
|
|
||||||
|
except ValidationError as ve:
|
||||||
|
logger.error("subscription_cancellation_validation_failed",
|
||||||
|
error=str(ve), tenant_id=tenant_id)
|
||||||
|
raise ve
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("subscription_cancellation_failed", error=str(e), tenant_id=tenant_id)
|
||||||
|
raise DatabaseError(f"Failed to cancel subscription: {str(e)}")
|
||||||
|
|
||||||
|
async def reactivate_subscription(
|
||||||
|
self,
|
||||||
|
tenant_id: str,
|
||||||
|
plan: str = "starter"
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Reactivate a cancelled or inactive subscription
|
||||||
|
|
||||||
|
Args:
|
||||||
|
tenant_id: Tenant ID to reactivate subscription for
|
||||||
|
plan: Plan to reactivate with
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary with reactivation details
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
tenant_uuid = UUID(tenant_id)
|
||||||
|
|
||||||
|
# Get subscription from repository
|
||||||
|
subscription = await self.subscription_repo.get_by_tenant_id(str(tenant_uuid))
|
||||||
|
|
||||||
|
if not subscription:
|
||||||
|
raise ValidationError(f"Subscription not found for tenant {tenant_id}")
|
||||||
|
|
||||||
|
if subscription.status not in ['pending_cancellation', 'inactive']:
|
||||||
|
raise ValidationError(f"Cannot reactivate subscription with status: {subscription.status}")
|
||||||
|
|
||||||
|
# Update subscription status and plan
|
||||||
|
update_data = {
|
||||||
|
'status': 'active',
|
||||||
|
'plan': plan,
|
||||||
|
'cancelled_at': None,
|
||||||
|
'cancellation_effective_date': None
|
||||||
|
}
|
||||||
|
|
||||||
|
if subscription.status == 'inactive':
|
||||||
|
update_data['next_billing_date'] = datetime.now(timezone.utc) + timedelta(days=30)
|
||||||
|
|
||||||
|
updated_subscription = await self.subscription_repo.update(str(subscription.id), update_data)
|
||||||
|
|
||||||
|
# Invalidate subscription cache
|
||||||
|
try:
|
||||||
|
from app.services.subscription_cache import get_subscription_cache_service
|
||||||
|
import shared.redis_utils
|
||||||
|
|
||||||
|
redis_client = await shared.redis_utils.initialize_redis(settings.REDIS_URL)
|
||||||
|
cache_service = get_subscription_cache_service(redis_client)
|
||||||
|
await cache_service.invalidate_subscription_cache(str(tenant_id))
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"Subscription cache invalidated after reactivation",
|
||||||
|
tenant_id=str(tenant_id)
|
||||||
|
)
|
||||||
|
except Exception as cache_error:
|
||||||
|
logger.error(
|
||||||
|
"Failed to invalidate subscription cache after reactivation",
|
||||||
|
tenant_id=str(tenant_id),
|
||||||
|
error=str(cache_error)
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"subscription_reactivated",
|
||||||
|
tenant_id=str(tenant_id),
|
||||||
|
new_plan=plan
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"message": "Subscription reactivated successfully",
|
||||||
|
"status": "active",
|
||||||
|
"plan": plan,
|
||||||
|
"next_billing_date": updated_subscription.next_billing_date.isoformat() if updated_subscription.next_billing_date else None
|
||||||
|
}
|
||||||
|
|
||||||
|
except ValidationError as ve:
|
||||||
|
logger.error("subscription_reactivation_validation_failed",
|
||||||
|
error=str(ve), tenant_id=tenant_id)
|
||||||
|
raise ve
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("subscription_reactivation_failed", error=str(e), tenant_id=tenant_id)
|
||||||
|
raise DatabaseError(f"Failed to reactivate subscription: {str(e)}")
|
||||||
|
|
||||||
|
async def get_subscription_status(
|
||||||
|
self,
|
||||||
|
tenant_id: str
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Get current subscription status including read-only mode info
|
||||||
|
|
||||||
|
Args:
|
||||||
|
tenant_id: Tenant ID to get status for
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary with subscription status details
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
tenant_uuid = UUID(tenant_id)
|
||||||
|
|
||||||
|
# Get subscription from repository
|
||||||
|
subscription = await self.subscription_repo.get_by_tenant_id(str(tenant_uuid))
|
||||||
|
|
||||||
|
if not subscription:
|
||||||
|
raise ValidationError(f"Subscription not found for tenant {tenant_id}")
|
||||||
|
|
||||||
|
is_read_only = subscription.status in ['pending_cancellation', 'inactive']
|
||||||
|
days_until_inactive = None
|
||||||
|
|
||||||
|
if subscription.status == 'pending_cancellation' and subscription.cancellation_effective_date:
|
||||||
|
days_until_inactive = (subscription.cancellation_effective_date - datetime.now(timezone.utc)).days
|
||||||
|
|
||||||
|
return {
|
||||||
|
"tenant_id": str(tenant_id),
|
||||||
|
"status": subscription.status,
|
||||||
|
"plan": subscription.plan,
|
||||||
|
"is_read_only": is_read_only,
|
||||||
|
"cancellation_effective_date": subscription.cancellation_effective_date.isoformat() if subscription.cancellation_effective_date else None,
|
||||||
|
"days_until_inactive": days_until_inactive
|
||||||
|
}
|
||||||
|
|
||||||
|
except ValidationError as ve:
|
||||||
|
logger.error("get_subscription_status_validation_failed",
|
||||||
|
error=str(ve), tenant_id=tenant_id)
|
||||||
|
raise ve
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("get_subscription_status_failed", error=str(e), tenant_id=tenant_id)
|
||||||
|
raise DatabaseError(f"Failed to get subscription status: {str(e)}")
|
||||||
|
|
||||||
|
async def get_tenant_invoices(
|
||||||
|
self,
|
||||||
|
tenant_id: str
|
||||||
|
) -> List[Dict[str, Any]]:
|
||||||
|
"""
|
||||||
|
Get invoice history for a tenant from payment provider
|
||||||
|
|
||||||
|
Args:
|
||||||
|
tenant_id: Tenant ID to get invoices for
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of invoice dictionaries
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
tenant_uuid = UUID(tenant_id)
|
||||||
|
|
||||||
|
# Verify tenant exists
|
||||||
|
query = select(Tenant).where(Tenant.id == tenant_uuid)
|
||||||
|
result = await self.db_session.execute(query)
|
||||||
|
tenant = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if not tenant:
|
||||||
|
raise ValidationError(f"Tenant not found: {tenant_id}")
|
||||||
|
|
||||||
|
# Check if tenant has a payment provider customer ID
|
||||||
|
if not tenant.stripe_customer_id:
|
||||||
|
logger.info("no_stripe_customer_id", tenant_id=tenant_id)
|
||||||
|
return []
|
||||||
|
|
||||||
|
# Initialize payment provider (Stripe in this case)
|
||||||
|
stripe_provider = StripeProvider(
|
||||||
|
api_key=settings.STRIPE_SECRET_KEY,
|
||||||
|
webhook_secret=settings.STRIPE_WEBHOOK_SECRET
|
||||||
|
)
|
||||||
|
|
||||||
|
# Fetch invoices from payment provider
|
||||||
|
stripe_invoices = await stripe_provider.get_invoices(tenant.stripe_customer_id)
|
||||||
|
|
||||||
|
# Transform to response format
|
||||||
|
invoices = []
|
||||||
|
for invoice in stripe_invoices:
|
||||||
|
invoices.append({
|
||||||
|
"id": invoice.id,
|
||||||
|
"date": invoice.created_at.strftime('%Y-%m-%d'),
|
||||||
|
"amount": invoice.amount,
|
||||||
|
"currency": invoice.currency.upper(),
|
||||||
|
"status": invoice.status,
|
||||||
|
"description": invoice.description,
|
||||||
|
"invoice_pdf": invoice.invoice_pdf,
|
||||||
|
"hosted_invoice_url": invoice.hosted_invoice_url
|
||||||
|
})
|
||||||
|
|
||||||
|
logger.info("invoices_retrieved", tenant_id=tenant_id, count=len(invoices))
|
||||||
|
return invoices
|
||||||
|
|
||||||
|
except ValidationError as ve:
|
||||||
|
logger.error("get_invoices_validation_failed",
|
||||||
|
error=str(ve), tenant_id=tenant_id)
|
||||||
|
raise ve
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("get_invoices_failed", error=str(e), tenant_id=tenant_id)
|
||||||
|
raise DatabaseError(f"Failed to retrieve invoices: {str(e)}")
|
||||||
|
|
||||||
|
async def create_subscription(
|
||||||
|
self,
|
||||||
|
tenant_id: str,
|
||||||
|
plan: str,
|
||||||
|
payment_method_id: str,
|
||||||
|
trial_period_days: Optional[int] = None
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Create a new subscription for a tenant
|
||||||
|
|
||||||
|
Args:
|
||||||
|
tenant_id: Tenant ID
|
||||||
|
plan: Subscription plan
|
||||||
|
payment_method_id: Payment method ID from payment provider
|
||||||
|
trial_period_days: Optional trial period in days
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary with subscription creation details
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
tenant_uuid = UUID(tenant_id)
|
||||||
|
|
||||||
|
# Verify tenant exists
|
||||||
|
query = select(Tenant).where(Tenant.id == tenant_uuid)
|
||||||
|
result = await self.db_session.execute(query)
|
||||||
|
tenant = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if not tenant:
|
||||||
|
raise ValidationError(f"Tenant not found: {tenant_id}")
|
||||||
|
|
||||||
|
if not tenant.stripe_customer_id:
|
||||||
|
raise ValidationError(f"Tenant {tenant_id} does not have a payment provider customer ID")
|
||||||
|
|
||||||
|
# Create subscription through payment provider
|
||||||
|
subscription_result = await self.payment_service.create_subscription(
|
||||||
|
tenant.stripe_customer_id,
|
||||||
|
plan,
|
||||||
|
payment_method_id,
|
||||||
|
trial_period_days
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create local subscription record
|
||||||
|
subscription_data = {
|
||||||
|
'tenant_id': str(tenant_id),
|
||||||
|
'stripe_subscription_id': subscription_result.id,
|
||||||
|
'plan': plan,
|
||||||
|
'status': subscription_result.status,
|
||||||
|
'current_period_start': subscription_result.current_period_start,
|
||||||
|
'current_period_end': subscription_result.current_period_end,
|
||||||
|
'created_at': datetime.now(timezone.utc),
|
||||||
|
'next_billing_date': subscription_result.current_period_end,
|
||||||
|
'trial_period_days': trial_period_days
|
||||||
|
}
|
||||||
|
|
||||||
|
created_subscription = await self.subscription_repo.create(subscription_data)
|
||||||
|
|
||||||
|
logger.info("subscription_created",
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
subscription_id=subscription_result.id,
|
||||||
|
plan=plan)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"subscription_id": subscription_result.id,
|
||||||
|
"status": subscription_result.status,
|
||||||
|
"plan": plan,
|
||||||
|
"current_period_end": subscription_result.current_period_end.isoformat()
|
||||||
|
}
|
||||||
|
|
||||||
|
except ValidationError as ve:
|
||||||
|
logger.error("create_subscription_validation_failed",
|
||||||
|
error=str(ve), tenant_id=tenant_id)
|
||||||
|
raise ve
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("create_subscription_failed", error=str(e), tenant_id=tenant_id)
|
||||||
|
raise DatabaseError(f"Failed to create subscription: {str(e)}")
|
||||||
|
|
||||||
|
async def get_subscription_by_tenant_id(
|
||||||
|
self,
|
||||||
|
tenant_id: str
|
||||||
|
) -> Optional[Subscription]:
|
||||||
|
"""
|
||||||
|
Get subscription by tenant ID
|
||||||
|
|
||||||
|
Args:
|
||||||
|
tenant_id: Tenant ID
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Subscription object or None
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
tenant_uuid = UUID(tenant_id)
|
||||||
|
return await self.subscription_repo.get_by_tenant_id(str(tenant_uuid))
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("get_subscription_by_tenant_id_failed",
|
||||||
|
error=str(e), tenant_id=tenant_id)
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def get_subscription_by_stripe_id(
|
||||||
|
self,
|
||||||
|
stripe_subscription_id: str
|
||||||
|
) -> Optional[Subscription]:
|
||||||
|
"""
|
||||||
|
Get subscription by Stripe subscription ID
|
||||||
|
|
||||||
|
Args:
|
||||||
|
stripe_subscription_id: Stripe subscription ID
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Subscription object or None
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
return await self.subscription_repo.get_by_stripe_id(stripe_subscription_id)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("get_subscription_by_stripe_id_failed",
|
||||||
|
error=str(e), stripe_subscription_id=stripe_subscription_id)
|
||||||
|
return None
|
||||||
Reference in New Issue
Block a user