Add subcription feature 2

This commit is contained in:
Urtzi Alfaro
2026-01-14 13:15:48 +01:00
parent 6ddf608d37
commit a4c3b7da3f
32 changed files with 4240 additions and 965 deletions

View File

@@ -200,8 +200,8 @@ async def clone_demo_data(
session_time,
"next_billing_date"
),
stripe_subscription_id=subscription_data.get('stripe_subscription_id'),
stripe_customer_id=subscription_data.get('stripe_customer_id'),
subscription_id=subscription_data.get('stripe_subscription_id'),
customer_id=subscription_data.get('stripe_customer_id'),
cancelled_at=parse_date_field(
subscription_data.get('cancelled_at'),
session_time,

View File

@@ -867,7 +867,7 @@ async def register_with_subscription(
return {
"success": True,
"message": "Registration and subscription created successfully",
"data": result
**result
}
except Exception as e:
logger.error("Failed to register with subscription", error=str(e))
@@ -924,7 +924,7 @@ async def create_subscription_endpoint(
return {
"success": True,
"message": "Subscription created successfully",
"data": result
**result
}
except Exception as e:
@@ -975,16 +975,30 @@ async def create_subscription_for_registration(
request.billing_interval,
request.coupon_code
)
# Check if result requires SetupIntent confirmation (3DS)
if result.get('requires_action'):
logger.info("Subscription creation requires SetupIntent confirmation",
user_id=request.user_data.get('user_id'),
action_type=result.get('action_type'),
setup_intent_id=result.get('setup_intent_id'))
return {
"success": True,
"message": "Payment method verification required",
**result # Spread all result fields to top level for frontend compatibility
}
# Normal subscription creation (no 3DS)
logger.info("Tenant-independent subscription created successfully",
user_id=request.user_data.get('user_id'),
subscription_id=result["subscription_id"],
subscription_id=result.get("subscription_id"),
plan_id=request.plan_id)
return {
"success": True,
"message": "Tenant-independent subscription created successfully",
"data": result
**result
}
except Exception as e:
@@ -998,6 +1012,136 @@ async def create_subscription_for_registration(
)
@router.post("/api/v1/subscriptions/complete-after-setup-intent")
async def complete_subscription_after_setup_intent(
request: dict = Body(..., description="Completion request with setup_intent_id"),
db: AsyncSession = Depends(get_db)
):
"""
Complete subscription creation after SetupIntent confirmation
This endpoint is called by the frontend after successfully confirming a SetupIntent
(with or without 3DS). It verifies the SetupIntent and creates the subscription.
Request body should contain:
- setup_intent_id: The SetupIntent ID that was confirmed
- customer_id: Stripe customer ID (from initial response)
- plan_id: Subscription plan ID
- payment_method_id: Payment method ID
- trial_period_days: Optional trial period
- user_id: User ID (for linking subscription to user)
"""
try:
setup_intent_id = request.get('setup_intent_id')
customer_id = request.get('customer_id')
plan_id = request.get('plan_id')
payment_method_id = request.get('payment_method_id')
trial_period_days = request.get('trial_period_days')
user_id = request.get('user_id')
billing_interval = request.get('billing_interval', 'monthly')
if not all([setup_intent_id, customer_id, plan_id, payment_method_id, user_id]):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Missing required fields: setup_intent_id, customer_id, plan_id, payment_method_id, user_id"
)
logger.info("Completing subscription after SetupIntent confirmation",
setup_intent_id=setup_intent_id,
user_id=user_id,
plan_id=plan_id)
# Use orchestration service to complete subscription
orchestration_service = SubscriptionOrchestrationService(db)
result = await orchestration_service.complete_subscription_after_setup_intent(
setup_intent_id=setup_intent_id,
customer_id=customer_id,
plan_id=plan_id,
payment_method_id=payment_method_id,
trial_period_days=trial_period_days,
user_id=user_id,
billing_interval=billing_interval
)
logger.info("Subscription completed successfully after SetupIntent",
setup_intent_id=setup_intent_id,
subscription_id=result.get('subscription_id'),
user_id=user_id)
return {
"success": True,
"message": "Subscription created successfully after SetupIntent confirmation",
**result
}
except HTTPException:
raise
except Exception as e:
logger.error("Failed to complete subscription after SetupIntent",
error=str(e),
setup_intent_id=request.get('setup_intent_id'))
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to complete subscription: {str(e)}"
)
@router.get("/api/v1/subscriptions/{tenant_id}/payment-method")
async def get_payment_method(
tenant_id: str = Path(..., description="Tenant ID"),
current_user: dict = Depends(get_current_user_dep),
db: AsyncSession = Depends(get_db)
):
"""
Get the current payment method for a subscription
This endpoint retrieves the current payment method details from the payment provider
for display in the UI, including brand, last4 digits, and expiration date.
"""
try:
# Use SubscriptionOrchestrationService to get payment method
orchestration_service = SubscriptionOrchestrationService(db)
payment_method = await orchestration_service.get_payment_method(tenant_id)
if not payment_method:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="No payment method found for this subscription"
)
logger.info("payment_method_retrieved_via_api",
tenant_id=tenant_id,
user_id=current_user.get("user_id"))
return payment_method
except HTTPException:
raise
except ValidationError as ve:
logger.error("get_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("get_payment_method_failed",
error=str(de), tenant_id=tenant_id)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to retrieve payment method"
)
except Exception as e:
logger.error("get_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 retrieving payment method"
)
@router.post("/api/v1/subscriptions/{tenant_id}/update-payment-method")
async def update_payment_method(
tenant_id: str = Path(..., description="Tenant ID"),
@@ -1007,60 +1151,43 @@ async def update_payment_method(
):
"""
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.
It updates the default payment method with the payment provider 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,
# Use SubscriptionOrchestrationService to update payment method
orchestration_service = SubscriptionOrchestrationService(db)
result = await orchestration_service.update_payment_method(
tenant_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)
}
return result
except ValidationError as ve:
logger.error("update_payment_method_validation_failed",
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",
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",
logger.error("update_payment_method_unexpected_error",
error=str(e), tenant_id=tenant_id)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
@@ -1377,3 +1504,118 @@ async def redeem_coupon(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="An unexpected error occurred while redeeming coupon"
)
# NEW ENDPOINTS FOR SECURE REGISTRATION ARCHITECTURE
class PaymentCustomerCreationRequest(BaseModel):
"""Request model for payment customer creation (pre-user-creation)"""
user_data: Dict[str, Any]
payment_method_id: Optional[str] = None
@router.post("/payment-customers/create")
async def create_payment_customer_for_registration(
request: PaymentCustomerCreationRequest,
db: AsyncSession = Depends(get_db)
):
"""
Create payment customer (supports pre-user-creation flow)
This endpoint creates a payment customer without requiring a user_id,
supporting the secure architecture where users are only created after
payment verification.
Uses SubscriptionOrchestrationService for proper workflow coordination.
Args:
request: Payment customer creation request
Returns:
Dictionary with payment customer creation result
"""
try:
logger.info("Creating payment customer for registration (pre-user creation)",
email=request.user_data.get('email'),
payment_method_id=request.payment_method_id)
# Use orchestration service for proper workflow coordination
orchestration_service = SubscriptionOrchestrationService(db)
result = await orchestration_service.create_registration_payment_setup(
user_data=request.user_data,
plan_id=request.plan_id if hasattr(request, 'plan_id') else "professional",
payment_method_id=request.payment_method_id,
billing_interval="monthly", # Default for registration
coupon_code=request.user_data.get('coupon_code')
)
logger.info("Payment setup completed for registration",
email=request.user_data.get('email'),
requires_action=result.get('requires_action'),
setup_intent_id=result.get('setup_intent_id'))
return {
"success": True,
**result # Include all orchestration service results
}
except Exception as e:
logger.error("Failed to create payment customer for registration",
email=request.user_data.get('email'),
error=str(e),
exc_info=True)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to create payment customer: " + str(e)
)
@router.get("/setup-intents/{setup_intent_id}/verify")
async def verify_setup_intent(
setup_intent_id: str,
db: AsyncSession = Depends(get_db)
):
"""
Verify SetupIntent status with payment provider
This endpoint checks if a SetupIntent has been successfully confirmed
(either automatically or via 3DS authentication).
Uses SubscriptionOrchestrationService for proper workflow coordination.
Args:
setup_intent_id: SetupIntent ID to verify
Returns:
Dictionary with SetupIntent verification result
"""
try:
logger.info("Verifying SetupIntent status",
setup_intent_id=setup_intent_id)
# Use orchestration service for proper workflow coordination
orchestration_service = SubscriptionOrchestrationService(db)
# Verify SetupIntent using orchestration service
result = await orchestration_service.verify_setup_intent_for_registration(
setup_intent_id
)
logger.info("SetupIntent verification result",
setup_intent_id=setup_intent_id,
status=result.get('status'))
return result
except Exception as e:
logger.error("Failed to verify SetupIntent",
setup_intent_id=setup_intent_id,
error=str(e),
exc_info=True)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to verify SetupIntent: " + str(e)
)

View File

@@ -1138,7 +1138,7 @@ async def register_with_subscription(
):
"""Process user registration with subscription creation"""
@router.post(route_builder.build_base_route("payment-customers/create", include_tenant_prefix=False))
@router.post("/api/v1/payment-customers/create")
async def create_payment_customer(
user_data: Dict[str, Any],
payment_method_id: Optional[str] = Query(None, description="Optional payment method ID"),
@@ -1146,7 +1146,7 @@ async def create_payment_customer(
):
"""
Create a payment customer in the payment provider
This endpoint is designed for service-to-service communication from auth service
during user registration. It creates a payment customer that can be used later
for subscription creation.
@@ -1241,7 +1241,7 @@ async def register_with_subscription(
return {
"success": True,
"message": "Registration and subscription created successfully",
"data": result
**result
}
except Exception as e:
logger.error("Failed to register with subscription", error=str(e))
@@ -1291,7 +1291,7 @@ async def link_subscription_to_tenant(
return {
"success": True,
"message": "Subscription linked to tenant successfully",
"data": result
**result
}
except Exception as e: