Add subcription feature
This commit is contained in:
@@ -2,10 +2,11 @@
|
||||
Subscription management API for GDPR-compliant cancellation and reactivation
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status, Query, Path
|
||||
from fastapi import APIRouter, Depends, HTTPException, status, Query, Path, Body
|
||||
from pydantic import BaseModel, Field
|
||||
from datetime import datetime, timezone, timedelta
|
||||
from uuid import UUID
|
||||
from typing import Optional, Dict, Any, List
|
||||
import structlog
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
@@ -17,6 +18,8 @@ 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 app.services.subscription_orchestration_service import SubscriptionOrchestrationService
|
||||
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
|
||||
@@ -134,9 +137,9 @@ async def cancel_subscription(
|
||||
5. Gateway enforces read-only mode for 'pending_cancellation' and 'inactive' statuses
|
||||
"""
|
||||
try:
|
||||
# Use service layer instead of direct database access
|
||||
subscription_service = SubscriptionService(db)
|
||||
result = await subscription_service.cancel_subscription(
|
||||
# Use orchestration service for complete workflow
|
||||
orchestration_service = SubscriptionOrchestrationService(db)
|
||||
result = await orchestration_service.orchestrate_subscription_cancellation(
|
||||
request.tenant_id,
|
||||
request.reason
|
||||
)
|
||||
@@ -195,9 +198,9 @@ async def reactivate_subscription(
|
||||
- inactive (after effective date)
|
||||
"""
|
||||
try:
|
||||
# Use service layer instead of direct database access
|
||||
subscription_service = SubscriptionService(db)
|
||||
result = await subscription_service.reactivate_subscription(
|
||||
# Use orchestration service for complete workflow
|
||||
orchestration_service = SubscriptionOrchestrationService(db)
|
||||
result = await orchestration_service.orchestrate_subscription_reactivation(
|
||||
request.tenant_id,
|
||||
request.plan
|
||||
)
|
||||
@@ -296,9 +299,10 @@ async def get_tenant_invoices(
|
||||
Get invoice history for a tenant from Stripe
|
||||
"""
|
||||
try:
|
||||
# Use service layer instead of direct database access
|
||||
# Use service layer for invoice retrieval
|
||||
subscription_service = SubscriptionService(db)
|
||||
invoices_data = await subscription_service.get_tenant_invoices(tenant_id)
|
||||
payment_service = PaymentService()
|
||||
invoices_data = await subscription_service.get_tenant_invoices(tenant_id, payment_service)
|
||||
|
||||
# Transform to response format
|
||||
invoices = []
|
||||
@@ -592,14 +596,25 @@ async def validate_plan_upgrade(
|
||||
async def upgrade_subscription_plan(
|
||||
tenant_id: str = Path(..., description="Tenant ID"),
|
||||
new_plan: str = Query(..., description="New plan name"),
|
||||
billing_cycle: Optional[str] = Query(None, description="Billing cycle (monthly/yearly)"),
|
||||
immediate_change: bool = Query(True, description="Apply change immediately"),
|
||||
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"""
|
||||
"""
|
||||
Upgrade subscription plan for a tenant.
|
||||
|
||||
This endpoint:
|
||||
1. Validates the upgrade is allowed
|
||||
2. Calculates proration costs
|
||||
3. Updates subscription in Stripe
|
||||
4. Updates local database
|
||||
5. Invalidates caches and tokens
|
||||
"""
|
||||
|
||||
try:
|
||||
# First validate the upgrade
|
||||
# Step 1: Validate the upgrade
|
||||
validation = await limit_service.validate_plan_upgrade(str(tenant_id), new_plan)
|
||||
if not validation.get("can_upgrade", False):
|
||||
raise HTTPException(
|
||||
@@ -607,10 +622,8 @@ async def upgrade_subscription_plan(
|
||||
detail=validation.get("reason", "Cannot upgrade to this plan")
|
||||
)
|
||||
|
||||
# Use SubscriptionService for the upgrade
|
||||
# Step 2: Get current subscription to determine billing cycle
|
||||
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(
|
||||
@@ -618,19 +631,23 @@ async def upgrade_subscription_plan(
|
||||
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
|
||||
# Use current billing cycle if not provided
|
||||
if not billing_cycle:
|
||||
billing_cycle = current_subscription.billing_interval or "monthly"
|
||||
|
||||
# Step 3: Use orchestration service for the upgrade
|
||||
from app.services.subscription_orchestration_service import SubscriptionOrchestrationService
|
||||
orchestration_service = SubscriptionOrchestrationService(db)
|
||||
|
||||
upgrade_result = await orchestration_service.orchestrate_plan_upgrade(
|
||||
tenant_id=str(tenant_id),
|
||||
new_plan=new_plan,
|
||||
proration_behavior="create_prorations",
|
||||
immediate_change=immediate_change,
|
||||
billing_cycle=billing_cycle
|
||||
)
|
||||
|
||||
# Invalidate subscription cache to ensure immediate availability of new tier
|
||||
# Step 4: Invalidate subscription cache
|
||||
try:
|
||||
from app.services.subscription_cache import get_subscription_cache_service
|
||||
import shared.redis_utils
|
||||
@@ -647,8 +664,7 @@ async def upgrade_subscription_plan(
|
||||
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
|
||||
# Step 5: Invalidate all existing tokens for this tenant
|
||||
try:
|
||||
redis_client = await get_redis_client()
|
||||
if redis_client:
|
||||
@@ -656,7 +672,7 @@ async def upgrade_subscription_plan(
|
||||
await redis_client.set(
|
||||
f"tenant:{tenant_id}:subscription_changed_at",
|
||||
str(changed_timestamp),
|
||||
ex=86400 # 24 hour TTL
|
||||
ex=86400
|
||||
)
|
||||
logger.info("Set subscription change timestamp for token invalidation",
|
||||
tenant_id=tenant_id,
|
||||
@@ -666,7 +682,7 @@ async def upgrade_subscription_plan(
|
||||
tenant_id=str(tenant_id),
|
||||
error=str(token_error))
|
||||
|
||||
# Also publish event for real-time notification
|
||||
# Step 6: Publish event for real-time notification
|
||||
try:
|
||||
from shared.messaging import UnifiedEventPublisher
|
||||
event_publisher = UnifiedEventPublisher()
|
||||
@@ -693,9 +709,9 @@ async def upgrade_subscription_plan(
|
||||
"message": f"Plan successfully upgraded to {new_plan}",
|
||||
"old_plan": current_subscription.plan,
|
||||
"new_plan": new_plan,
|
||||
"new_monthly_price": updated_subscription.monthly_price,
|
||||
"proration_details": upgrade_result.get("proration_details"),
|
||||
"validation": validation,
|
||||
"requires_token_refresh": True # Signal to frontend
|
||||
"requires_token_refresh": True
|
||||
}
|
||||
|
||||
except HTTPException:
|
||||
@@ -707,16 +723,130 @@ async def upgrade_subscription_plan(
|
||||
error=str(e))
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Failed to upgrade subscription plan"
|
||||
detail=f"Failed to upgrade subscription plan: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@router.post("/api/v1/subscriptions/{tenant_id}/change-billing-cycle")
|
||||
async def change_billing_cycle(
|
||||
tenant_id: str = Path(..., description="Tenant ID"),
|
||||
new_billing_cycle: str = Query(..., description="New billing cycle (monthly/yearly)"),
|
||||
current_user: dict = Depends(get_current_user_dep),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Change billing cycle for a tenant's subscription.
|
||||
|
||||
This endpoint:
|
||||
1. Validates the tenant has an active subscription
|
||||
2. Calculates proration costs
|
||||
3. Updates subscription in Stripe
|
||||
4. Updates local database
|
||||
5. Returns proration details to user
|
||||
"""
|
||||
|
||||
try:
|
||||
# Validate billing cycle parameter
|
||||
if new_billing_cycle not in ["monthly", "yearly"]:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Billing cycle must be 'monthly' or 'yearly'"
|
||||
)
|
||||
|
||||
# Get current subscription
|
||||
subscription_service = SubscriptionService(db)
|
||||
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"
|
||||
)
|
||||
|
||||
# Check if already on requested billing cycle
|
||||
current_cycle = current_subscription.billing_interval or "monthly"
|
||||
if current_cycle == new_billing_cycle:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Subscription is already on {new_billing_cycle} billing"
|
||||
)
|
||||
|
||||
# Use orchestration service for the billing cycle change
|
||||
from app.services.subscription_orchestration_service import SubscriptionOrchestrationService
|
||||
orchestration_service = SubscriptionOrchestrationService(db)
|
||||
|
||||
change_result = await orchestration_service.orchestrate_billing_cycle_change(
|
||||
tenant_id=str(tenant_id),
|
||||
new_billing_cycle=new_billing_cycle,
|
||||
immediate_change=True
|
||||
)
|
||||
|
||||
# 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 billing cycle change",
|
||||
tenant_id=str(tenant_id),
|
||||
new_billing_cycle=new_billing_cycle)
|
||||
except Exception as cache_error:
|
||||
logger.error("Failed to invalidate subscription cache",
|
||||
tenant_id=str(tenant_id),
|
||||
error=str(cache_error))
|
||||
|
||||
# Publish event for real-time notification
|
||||
try:
|
||||
from shared.messaging import UnifiedEventPublisher
|
||||
event_publisher = UnifiedEventPublisher()
|
||||
await event_publisher.publish_business_event(
|
||||
event_type="subscription.billing_cycle_changed",
|
||||
tenant_id=str(tenant_id),
|
||||
data={
|
||||
"tenant_id": str(tenant_id),
|
||||
"old_billing_cycle": current_cycle,
|
||||
"new_billing_cycle": new_billing_cycle,
|
||||
"action": "billing_cycle_change"
|
||||
}
|
||||
)
|
||||
logger.info("Published billing cycle change event",
|
||||
tenant_id=str(tenant_id))
|
||||
except Exception as event_error:
|
||||
logger.error("Failed to publish billing cycle change event",
|
||||
tenant_id=str(tenant_id),
|
||||
error=str(event_error))
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"message": f"Billing cycle changed to {new_billing_cycle}",
|
||||
"old_billing_cycle": current_cycle,
|
||||
"new_billing_cycle": new_billing_cycle,
|
||||
"proration_details": change_result.get("proration_details")
|
||||
}
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error("Failed to change billing cycle",
|
||||
tenant_id=str(tenant_id),
|
||||
new_billing_cycle=new_billing_cycle,
|
||||
error=str(e))
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Failed to change billing cycle: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@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"),
|
||||
plan_id: str = Query(..., description="Plan ID to subscribe to (starter, professional, enterprise)"),
|
||||
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"),
|
||||
coupon_code: Optional[str] = Query(None, description="Coupon code to apply (e.g., PILOT2025)"),
|
||||
billing_interval: str = Query("monthly", description="Billing interval (monthly or yearly)"),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Process user registration with subscription creation"""
|
||||
@@ -729,7 +859,9 @@ async def register_with_subscription(
|
||||
user_data.get('tenant_id'),
|
||||
plan_id,
|
||||
payment_method_id,
|
||||
14 if use_trial else None
|
||||
None, # Trial period handled by coupon logic
|
||||
billing_interval,
|
||||
coupon_code # Pass coupon code for trial period determination
|
||||
)
|
||||
|
||||
return {
|
||||
@@ -745,6 +877,127 @@ async def register_with_subscription(
|
||||
)
|
||||
|
||||
|
||||
@router.post("/api/v1/subscriptions/{tenant_id}/create")
|
||||
async def create_subscription_endpoint(
|
||||
tenant_id: str = Path(..., description="Tenant ID"),
|
||||
plan_id: str = Query(..., description="Plan ID (starter, professional, enterprise)"),
|
||||
payment_method_id: str = Query(..., description="Payment method ID from frontend"),
|
||||
billing_interval: str = Query("monthly", description="Billing interval (monthly or yearly)"),
|
||||
trial_period_days: Optional[int] = Query(None, description="Trial period in days"),
|
||||
coupon_code: Optional[str] = Query(None, description="Optional coupon code"),
|
||||
current_user: dict = Depends(get_current_user_dep),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Create a new subscription for a tenant using orchestration service
|
||||
|
||||
This endpoint orchestrates the complete subscription creation workflow
|
||||
including payment provider integration and tenant updates.
|
||||
"""
|
||||
try:
|
||||
# Prepare user data for orchestration service
|
||||
user_data = {
|
||||
'user_id': current_user.get('sub'),
|
||||
'email': current_user.get('email'),
|
||||
'full_name': current_user.get('name', 'Unknown User'),
|
||||
'tenant_id': tenant_id
|
||||
}
|
||||
|
||||
# Use orchestration service for complete workflow
|
||||
orchestration_service = SubscriptionOrchestrationService(db)
|
||||
|
||||
result = await orchestration_service.orchestrate_subscription_creation(
|
||||
tenant_id,
|
||||
user_data,
|
||||
plan_id,
|
||||
payment_method_id,
|
||||
billing_interval,
|
||||
coupon_code
|
||||
)
|
||||
|
||||
logger.info("subscription_created_via_orchestration",
|
||||
tenant_id=tenant_id,
|
||||
plan_id=plan_id,
|
||||
billing_interval=billing_interval,
|
||||
coupon_applied=result.get("coupon_applied", False))
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"message": "Subscription created successfully",
|
||||
"data": result
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Failed to create subscription via API",
|
||||
error=str(e),
|
||||
tenant_id=tenant_id,
|
||||
plan_id=plan_id)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Failed to create subscription"
|
||||
)
|
||||
|
||||
class CreateForRegistrationRequest(BaseModel):
|
||||
"""Request model for create-for-registration endpoint"""
|
||||
user_data: dict = Field(..., description="User data for subscription creation")
|
||||
plan_id: str = Field(..., description="Plan ID (starter, professional, enterprise)")
|
||||
payment_method_id: str = Field(..., description="Payment method ID from frontend")
|
||||
billing_interval: str = Field("monthly", description="Billing interval (monthly or yearly)")
|
||||
coupon_code: Optional[str] = Field(None, description="Optional coupon code")
|
||||
|
||||
|
||||
@router.post("/api/v1/subscriptions/create-for-registration")
|
||||
async def create_subscription_for_registration(
|
||||
request: CreateForRegistrationRequest = Body(..., description="Subscription creation request"),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Create a tenant-independent subscription during user registration
|
||||
|
||||
This endpoint creates a subscription that is not linked to any tenant.
|
||||
The subscription will be linked to a tenant during the onboarding flow.
|
||||
|
||||
This is used during the new registration flow where users register
|
||||
and pay before creating their tenant/bakery.
|
||||
"""
|
||||
try:
|
||||
logger.info("Creating tenant-independent subscription for registration",
|
||||
user_id=request.user_data.get('user_id'),
|
||||
plan_id=request.plan_id)
|
||||
|
||||
# Use orchestration service for tenant-independent subscription creation
|
||||
orchestration_service = SubscriptionOrchestrationService(db)
|
||||
|
||||
result = await orchestration_service.create_tenant_independent_subscription(
|
||||
request.user_data,
|
||||
request.plan_id,
|
||||
request.payment_method_id,
|
||||
request.billing_interval,
|
||||
request.coupon_code
|
||||
)
|
||||
|
||||
logger.info("Tenant-independent subscription created successfully",
|
||||
user_id=request.user_data.get('user_id'),
|
||||
subscription_id=result["subscription_id"],
|
||||
plan_id=request.plan_id)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"message": "Tenant-independent subscription created successfully",
|
||||
"data": result
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Failed to create tenant-independent subscription",
|
||||
error=str(e),
|
||||
user_id=request.user_data.get('user_id'),
|
||||
plan_id=request.plan_id)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Failed to create tenant-independent subscription"
|
||||
)
|
||||
|
||||
|
||||
@router.post("/api/v1/subscriptions/{tenant_id}/update-payment-method")
|
||||
async def update_payment_method(
|
||||
tenant_id: str = Path(..., description="Tenant ID"),
|
||||
@@ -813,3 +1066,314 @@ async def update_payment_method(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="An unexpected error occurred while updating payment method"
|
||||
)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# NEW SUBSCRIPTION UPDATE ENDPOINTS WITH PRORATION SUPPORT
|
||||
# ============================================================================
|
||||
|
||||
class SubscriptionChangePreviewRequest(BaseModel):
|
||||
"""Request model for subscription change preview"""
|
||||
new_plan: str = Field(..., description="New plan name (starter, professional, enterprise) or 'same' for billing cycle changes")
|
||||
proration_behavior: str = Field("create_prorations", description="Proration behavior (create_prorations, none, always_invoice)")
|
||||
billing_cycle: str = Field("monthly", description="Billing cycle for the new plan (monthly, yearly)")
|
||||
|
||||
|
||||
class SubscriptionChangePreviewResponse(BaseModel):
|
||||
"""Response model for subscription change preview"""
|
||||
success: bool
|
||||
current_plan: str
|
||||
current_billing_cycle: str
|
||||
current_price: float
|
||||
new_plan: str
|
||||
new_billing_cycle: str
|
||||
new_price: float
|
||||
proration_details: Dict[str, Any]
|
||||
current_plan_features: List[str]
|
||||
new_plan_features: List[str]
|
||||
change_type: str
|
||||
|
||||
|
||||
@router.post("/api/v1/subscriptions/{tenant_id}/preview-change", response_model=SubscriptionChangePreviewResponse)
|
||||
async def preview_subscription_change(
|
||||
tenant_id: str = Path(..., description="Tenant ID"),
|
||||
request: SubscriptionChangePreviewRequest = Body(...),
|
||||
current_user: dict = Depends(get_current_user_dep),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Preview the cost impact of a subscription change
|
||||
|
||||
This endpoint allows users to see the proration details before confirming a subscription change.
|
||||
It shows the cost difference, credits, and other financial impacts of changing plans or billing cycles.
|
||||
"""
|
||||
try:
|
||||
# Use SubscriptionService for preview
|
||||
subscription_service = SubscriptionService(db)
|
||||
|
||||
# Create payment service for proration calculation
|
||||
payment_service = PaymentService()
|
||||
result = await subscription_service.preview_subscription_change(
|
||||
tenant_id,
|
||||
request.new_plan,
|
||||
request.proration_behavior,
|
||||
request.billing_cycle,
|
||||
payment_service
|
||||
)
|
||||
|
||||
logger.info("subscription_change_previewed",
|
||||
tenant_id=tenant_id,
|
||||
user_id=current_user.get("user_id"),
|
||||
new_plan=request.new_plan,
|
||||
proration_amount=result["proration_details"].get("net_amount", 0))
|
||||
|
||||
return SubscriptionChangePreviewResponse(**result)
|
||||
|
||||
except ValidationError as ve:
|
||||
logger.error("preview_subscription_change_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("preview_subscription_change_failed",
|
||||
error=str(de), tenant_id=tenant_id)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Failed to preview subscription change"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error("preview_subscription_change_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 previewing subscription change"
|
||||
)
|
||||
|
||||
|
||||
class SubscriptionPlanUpdateRequest(BaseModel):
|
||||
"""Request model for subscription plan update"""
|
||||
new_plan: str = Field(..., description="New plan name (starter, professional, enterprise)")
|
||||
proration_behavior: str = Field("create_prorations", description="Proration behavior (create_prorations, none, always_invoice)")
|
||||
immediate_change: bool = Field(False, description="Whether to apply changes immediately or at period end")
|
||||
billing_cycle: str = Field("monthly", description="Billing cycle for the new plan (monthly, yearly)")
|
||||
|
||||
|
||||
class SubscriptionPlanUpdateResponse(BaseModel):
|
||||
"""Response model for subscription plan update"""
|
||||
success: bool
|
||||
message: str
|
||||
old_plan: str
|
||||
new_plan: str
|
||||
proration_details: Dict[str, Any]
|
||||
immediate_change: bool
|
||||
new_status: str
|
||||
new_period_end: str
|
||||
|
||||
|
||||
@router.post("/api/v1/subscriptions/{tenant_id}/update-plan", response_model=SubscriptionPlanUpdateResponse)
|
||||
async def update_subscription_plan(
|
||||
tenant_id: str = Path(..., description="Tenant ID"),
|
||||
request: SubscriptionPlanUpdateRequest = Body(...),
|
||||
current_user: dict = Depends(get_current_user_dep),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Update subscription plan with proration support
|
||||
|
||||
This endpoint allows users to change their subscription plan with proper proration handling.
|
||||
It supports both immediate changes and changes that take effect at the end of the billing period.
|
||||
"""
|
||||
try:
|
||||
# Use orchestration service for complete plan upgrade workflow
|
||||
orchestration_service = SubscriptionOrchestrationService(db)
|
||||
|
||||
result = await orchestration_service.orchestrate_plan_upgrade(
|
||||
tenant_id,
|
||||
request.new_plan,
|
||||
request.proration_behavior,
|
||||
request.immediate_change,
|
||||
request.billing_cycle
|
||||
)
|
||||
|
||||
logger.info("subscription_plan_updated",
|
||||
tenant_id=tenant_id,
|
||||
user_id=current_user.get("user_id"),
|
||||
old_plan=result["old_plan"],
|
||||
new_plan=result["new_plan"],
|
||||
proration_amount=result["proration_details"].get("net_amount", 0),
|
||||
immediate_change=request.immediate_change)
|
||||
|
||||
return SubscriptionPlanUpdateResponse(**result)
|
||||
|
||||
except ValidationError as ve:
|
||||
logger.error("update_subscription_plan_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_subscription_plan_failed",
|
||||
error=str(de), tenant_id=tenant_id)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Failed to update subscription plan"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error("update_subscription_plan_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 subscription plan"
|
||||
)
|
||||
|
||||
|
||||
class BillingCycleChangeRequest(BaseModel):
|
||||
"""Request model for billing cycle change"""
|
||||
new_billing_cycle: str = Field(..., description="New billing cycle (monthly, yearly)")
|
||||
proration_behavior: str = Field("create_prorations", description="Proration behavior (create_prorations, none, always_invoice)")
|
||||
|
||||
|
||||
class BillingCycleChangeResponse(BaseModel):
|
||||
"""Response model for billing cycle change"""
|
||||
success: bool
|
||||
message: str
|
||||
old_billing_cycle: str
|
||||
new_billing_cycle: str
|
||||
proration_details: Dict[str, Any]
|
||||
new_status: str
|
||||
new_period_end: str
|
||||
|
||||
|
||||
@router.post("/api/v1/subscriptions/{tenant_id}/change-billing-cycle", response_model=BillingCycleChangeResponse)
|
||||
async def change_billing_cycle(
|
||||
tenant_id: str = Path(..., description="Tenant ID"),
|
||||
request: BillingCycleChangeRequest = Body(...),
|
||||
current_user: dict = Depends(get_current_user_dep),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Change billing cycle (monthly ↔ yearly) for a subscription
|
||||
|
||||
This endpoint allows users to switch between monthly and yearly billing cycles.
|
||||
It handles proration and creates appropriate charges or credits.
|
||||
"""
|
||||
try:
|
||||
# Use orchestration service for complete billing cycle change workflow
|
||||
orchestration_service = SubscriptionOrchestrationService(db)
|
||||
|
||||
result = await orchestration_service.orchestrate_billing_cycle_change(
|
||||
tenant_id,
|
||||
request.new_billing_cycle,
|
||||
request.proration_behavior
|
||||
)
|
||||
|
||||
logger.info("subscription_billing_cycle_changed",
|
||||
tenant_id=tenant_id,
|
||||
user_id=current_user.get("user_id"),
|
||||
old_billing_cycle=result["old_billing_cycle"],
|
||||
new_billing_cycle=result["new_billing_cycle"],
|
||||
proration_amount=result["proration_details"].get("net_amount", 0))
|
||||
|
||||
return BillingCycleChangeResponse(**result)
|
||||
|
||||
except ValidationError as ve:
|
||||
logger.error("change_billing_cycle_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("change_billing_cycle_failed",
|
||||
error=str(de), tenant_id=tenant_id)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Failed to change billing cycle"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error("change_billing_cycle_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 changing billing cycle"
|
||||
)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# COUPON REDEMPTION ENDPOINTS
|
||||
# ============================================================================
|
||||
|
||||
class CouponRedemptionRequest(BaseModel):
|
||||
"""Request model for coupon redemption"""
|
||||
coupon_code: str = Field(..., description="Coupon code to redeem")
|
||||
base_trial_days: int = Field(14, description="Base trial days without coupon")
|
||||
|
||||
class CouponRedemptionResponse(BaseModel):
|
||||
"""Response model for coupon redemption"""
|
||||
success: bool
|
||||
coupon_applied: bool
|
||||
discount: Optional[Dict[str, Any]] = None
|
||||
message: str
|
||||
error: Optional[str] = None
|
||||
|
||||
@router.post("/api/v1/subscriptions/{tenant_id}/redeem-coupon", response_model=CouponRedemptionResponse)
|
||||
async def redeem_coupon(
|
||||
tenant_id: str = Path(..., description="Tenant ID"),
|
||||
request: CouponRedemptionRequest = Body(...),
|
||||
current_user: dict = Depends(get_current_user_dep),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Redeem a coupon for a tenant
|
||||
|
||||
This endpoint handles the complete coupon redemption workflow including
|
||||
validation, redemption, and tenant updates.
|
||||
"""
|
||||
try:
|
||||
# Use orchestration service for complete coupon redemption workflow
|
||||
orchestration_service = SubscriptionOrchestrationService(db)
|
||||
|
||||
result = await orchestration_service.orchestrate_coupon_redemption(
|
||||
tenant_id,
|
||||
request.coupon_code,
|
||||
request.base_trial_days
|
||||
)
|
||||
|
||||
logger.info("coupon_redeemed",
|
||||
tenant_id=tenant_id,
|
||||
user_id=current_user.get("user_id"),
|
||||
coupon_code=request.coupon_code,
|
||||
success=result["success"])
|
||||
|
||||
return CouponRedemptionResponse(
|
||||
success=result["success"],
|
||||
coupon_applied=result.get("coupon_applied", False),
|
||||
discount=result.get("discount"),
|
||||
message=result.get("message", "Coupon redemption processed"),
|
||||
error=result.get("error")
|
||||
)
|
||||
|
||||
except ValidationError as ve:
|
||||
logger.error("coupon_redemption_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("coupon_redemption_failed", error=str(de), tenant_id=tenant_id)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Failed to redeem coupon"
|
||||
)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error("coupon_redemption_failed", error=str(e), tenant_id=tenant_id)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="An unexpected error occurred while redeeming coupon"
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user