Add subcription feature

This commit is contained in:
Urtzi Alfaro
2026-01-13 22:22:38 +01:00
parent b931a5c45e
commit 6ddf608d37
61 changed files with 7915 additions and 1238 deletions

View File

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