Refactor subcription layer

This commit is contained in:
Urtzi Alfaro
2026-01-11 21:40:04 +01:00
parent 54163843ec
commit 55bb1c6451
7 changed files with 1369 additions and 300 deletions

View File

@@ -402,6 +402,24 @@ export class SubscriptionService {
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
// ============================================================================

View File

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

View File

@@ -10,6 +10,7 @@ import { subscriptionService, type UsageSummary, type AvailablePlans } from '../
import { useSubscriptionEvents } from '../../../../contexts/SubscriptionEventsContext';
import { SubscriptionPricingCards } from '../../../../components/subscription/SubscriptionPricingCards';
import { PlanComparisonTable, ROICalculator, UsageMetricCard } from '../../../../components/subscription';
import { PaymentMethodUpdateModal } from '../../../../components/subscription/PaymentMethodUpdateModal';
import { useSubscription } from '../../../../hooks/useSubscription';
import {
trackSubscriptionPageViewed,
@@ -41,6 +42,13 @@ const SubscriptionPage: React.FC = () => {
// New state for enhanced features
const [showComparison, setShowComparison] = 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
const { subscription: subscriptionData, usage: forecastUsage, forecast } = useSubscription();
@@ -916,7 +924,7 @@ const SubscriptionPage: React.FC = () => {
<Button
variant="outline"
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" />
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 */}
{upgradeDialogOpen && selectedPlan && availablePlans && (
<DialogModal

View File

@@ -2,27 +2,65 @@
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 datetime import datetime, timezone, timedelta
from uuid import UUID
import structlog
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
import json
from shared.auth.decorators import get_current_user_dep, require_admin_role_dep
from shared.routing import RouteBuilder
from app.core.database import get_db
from app.models.tenants import Subscription, Tenant
from app.services.subscription_limit_service import SubscriptionLimitService
from app.services.subscription_service import SubscriptionService
from shared.clients.stripe_client import StripeProvider
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()
router = APIRouter()
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):
"""Response for quota limit checks"""
allowed: bool
@@ -96,74 +134,43 @@ async def cancel_subscription(
5. Gateway enforces read-only mode for 'pending_cancellation' and 'inactive' statuses
"""
try:
tenant_id = UUID(request.tenant_id)
query = select(Subscription).where(Subscription.tenant_id == tenant_id)
result = await db.execute(query)
subscription = result.scalar_one_or_none()
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)
# Use service layer instead of direct database access
subscription_service = SubscriptionService(db)
result = await subscription_service.cancel_subscription(
request.tenant_id,
request.reason
)
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(
"subscription_cancelled",
tenant_id=str(tenant_id),
tenant_id=request.tenant_id,
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
)
return SubscriptionCancellationResponse(
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()
success=result["success"],
message=result["message"],
status=result["status"],
cancellation_effective_date=result["cancellation_effective_date"],
days_remaining=result["days_remaining"],
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:
raise
except Exception as e:
@@ -188,70 +195,41 @@ async def reactivate_subscription(
- inactive (after effective date)
"""
try:
tenant_id = UUID(request.tenant_id)
query = select(Subscription).where(Subscription.tenant_id == tenant_id)
result = await db.execute(query)
subscription = result.scalar_one_or_none()
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)
)
# Use service layer instead of direct database access
subscription_service = SubscriptionService(db)
result = await subscription_service.reactivate_subscription(
request.tenant_id,
request.plan
)
logger.info(
"subscription_reactivated",
tenant_id=str(tenant_id),
tenant_id=request.tenant_id,
user_id=current_user.get("sub"),
new_plan=request.plan
)
return {
"success": True,
"message": "Subscription reactivated successfully",
"status": "active",
"plan": subscription.plan,
"next_billing_date": subscription.next_billing_date.isoformat() if subscription.next_billing_date else None
"success": result["success"],
"message": result["message"],
"status": result["status"],
"plan": result["plan"],
"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:
raise
except Exception as e:
@@ -272,31 +250,32 @@ async def get_subscription_status(
Get current subscription status including read-only mode info
"""
try:
query = select(Subscription).where(Subscription.tenant_id == UUID(tenant_id))
result = await db.execute(query)
subscription = result.scalar_one_or_none()
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
# Use service layer instead of direct database access
subscription_service = SubscriptionService(db)
result = await subscription_service.get_subscription_status(tenant_id)
return SubscriptionStatusResponse(
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
tenant_id=result["tenant_id"],
status=result["status"],
plan=result["plan"],
is_read_only=result["is_read_only"],
cancellation_effective_date=result["cancellation_effective_date"],
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:
raise
except Exception as e:
@@ -317,48 +296,40 @@ async def get_tenant_invoices(
Get invoice history for a tenant from Stripe
"""
try:
# Verify tenant exists
query = select(Tenant).where(Tenant.id == UUID(tenant_id))
result = await db.execute(query)
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)
# Use service layer instead of direct database access
subscription_service = SubscriptionService(db)
invoices_data = await subscription_service.get_tenant_invoices(tenant_id)
# Transform to response format
invoices = []
for invoice in stripe_invoices:
for invoice_data in invoices_data:
invoices.append(InvoiceResponse(
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
id=invoice_data["id"],
date=invoice_data["date"],
amount=invoice_data["amount"],
currency=invoice_data["currency"],
status=invoice_data["status"],
description=invoice_data["description"],
invoice_pdf=invoice_data["invoice_pdf"],
hosted_invoice_url=invoice_data["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 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:
raise
except Exception as e:
@@ -367,3 +338,478 @@ async def get_tenant_invoices(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
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"
)

View File

@@ -1095,139 +1095,6 @@ async def 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):
"""

View File

@@ -102,6 +102,42 @@ class SubscriptionRepository(TenantBaseRepository):
error=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]:
"""Get active subscription for tenant"""
try:

View 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