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:

View File

@@ -171,8 +171,10 @@ class Subscription(Base):
trial_ends_at = Column(DateTime(timezone=True))
cancelled_at = Column(DateTime(timezone=True), nullable=True)
cancellation_effective_date = Column(DateTime(timezone=True), nullable=True)
stripe_subscription_id = Column(String(255), nullable=True)
stripe_customer_id = Column(String(255), nullable=True)
# Payment provider references (generic names for provider-agnostic design)
subscription_id = Column(String(255), nullable=True) # Payment provider subscription ID
customer_id = Column(String(255), nullable=True) # Payment provider customer ID
# Limits
max_users = Column(Integer, default=5)

View File

@@ -120,12 +120,12 @@ class SubscriptionRepository(TenantBaseRepository):
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"""
async def get_by_provider_id(self, subscription_id: str) -> Optional[Subscription]:
"""Get subscription by payment provider subscription ID"""
try:
subscriptions = await self.get_multi(
filters={
"stripe_subscription_id": stripe_subscription_id
"subscription_id": subscription_id
},
limit=1,
order_by="created_at",
@@ -133,8 +133,8 @@ class SubscriptionRepository(TenantBaseRepository):
)
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,
logger.error("Failed to get subscription by provider ID",
subscription_id=subscription_id,
error=str(e))
raise DatabaseError(f"Failed to get subscription: {str(e)}")
@@ -514,7 +514,7 @@ class SubscriptionRepository(TenantBaseRepository):
"""Create a subscription not linked to any tenant (for registration flow)"""
try:
# Validate required data for tenant-independent subscription
required_fields = ["user_id", "plan", "stripe_subscription_id", "stripe_customer_id"]
required_fields = ["user_id", "plan", "subscription_id", "customer_id"]
validation_result = self._validate_tenant_data(subscription_data, required_fields)
if not validation_result["is_valid"]:

View File

@@ -396,6 +396,10 @@ class TenantRepository(TenantBaseRepository):
error=str(e))
raise DatabaseError(f"Failed to get child tenants: {str(e)}")
async def get(self, record_id: Any) -> Optional[Tenant]:
"""Get tenant by ID - alias for get_by_id for compatibility"""
return await self.get_by_id(record_id)
async def get_child_tenant_count(self, parent_tenant_id: str) -> int:
"""Get count of child tenants for a parent tenant"""
try:

View File

@@ -4,8 +4,9 @@ This service handles ONLY payment provider interactions (Stripe, etc.)
NO business logic, NO database operations, NO orchestration
"""
import asyncio
import structlog
from typing import Dict, Any, Optional, List
from typing import Dict, Any, Optional, List, Callable, Type
from datetime import datetime
from app.core.config import settings
@@ -15,6 +16,51 @@ from shared.clients.stripe_client import StripeProvider
logger = structlog.get_logger()
async def retry_with_backoff(
func: Callable,
max_retries: int = 3,
base_delay: float = 1.0,
max_delay: float = 10.0,
exceptions: tuple = (Exception,)
):
"""
Generic retry function with exponential backoff
Args:
func: The async function to retry
max_retries: Maximum number of retry attempts
base_delay: Initial delay between retries in seconds
max_delay: Maximum delay between retries in seconds
exceptions: Tuple of exception types to retry on
"""
for attempt in range(max_retries + 1):
try:
return await func()
except exceptions as e:
if attempt == max_retries:
# Last attempt, re-raise the exception
raise e
# Calculate delay with exponential backoff and jitter
delay = min(base_delay * (2 ** attempt), max_delay)
jitter = delay * 0.1 # 10% jitter
actual_delay = delay + (jitter * (attempt % 2)) # Alternate between + and - jitter
logger.warning(
"Payment provider API call failed, retrying",
attempt=attempt + 1,
max_retries=max_retries,
delay=actual_delay,
error=str(e),
error_type=type(e).__name__
)
await asyncio.sleep(actual_delay)
# This should never be reached, but included for completeness
raise Exception("Max retries exceeded")
class PaymentService:
"""Service for handling payment provider interactions ONLY"""
@@ -37,7 +83,13 @@ class PaymentService:
}
}
return await self.payment_provider.create_customer(customer_data)
# Use retry logic for transient Stripe API failures
result = await retry_with_backoff(
lambda: self.payment_provider.create_customer(customer_data),
max_retries=3,
exceptions=(Exception,) # Catch all exceptions for payment provider calls
)
return result
except Exception as e:
logger.error("Failed to create customer in payment provider", error=str(e))
raise e
@@ -49,7 +101,7 @@ class PaymentService:
payment_method_id: str,
trial_period_days: Optional[int] = None,
billing_interval: str = "monthly"
) -> Subscription:
) -> Dict[str, Any]:
"""
Create a subscription in the payment provider
@@ -61,18 +113,25 @@ class PaymentService:
billing_interval: Billing interval (monthly/yearly)
Returns:
Subscription object from payment provider
Dictionary containing subscription and authentication details
"""
try:
# Map the plan ID to the actual Stripe price ID
stripe_price_id = self._get_stripe_price_id(plan_id, billing_interval)
return await self.payment_provider.create_subscription(
customer_id,
stripe_price_id,
payment_method_id,
trial_period_days
# Use retry logic for transient Stripe API failures
result = await retry_with_backoff(
lambda: self.payment_provider.create_subscription(
customer_id,
stripe_price_id,
payment_method_id,
trial_period_days
),
max_retries=3,
exceptions=(Exception,) # Catch all exceptions for payment provider calls
)
return result
except Exception as e:
logger.error("Failed to create subscription in payment provider",
error=str(e),
@@ -127,7 +186,7 @@ class PaymentService:
logger.error("Failed to cancel subscription in payment provider", error=str(e))
raise e
async def update_payment_method(self, customer_id: str, payment_method_id: str) -> PaymentMethod:
async def update_payment_method(self, customer_id: str, payment_method_id: str) -> Dict[str, Any]:
"""
Update the payment method for a customer
@@ -136,10 +195,16 @@ class PaymentService:
payment_method_id: New payment method ID
Returns:
PaymentMethod object
Dictionary containing payment method and authentication details
"""
try:
return await self.payment_provider.update_payment_method(customer_id, payment_method_id)
# Use retry logic for transient Stripe API failures
result = await retry_with_backoff(
lambda: self.payment_provider.update_payment_method(customer_id, payment_method_id),
max_retries=3,
exceptions=(Exception,) # Catch all exceptions for payment provider calls
)
return result
except Exception as e:
logger.error("Failed to update payment method in payment provider", error=str(e))
raise e
@@ -155,11 +220,76 @@ class PaymentService:
Subscription object
"""
try:
return await self.payment_provider.get_subscription(subscription_id)
# Use retry logic for transient Stripe API failures
result = await retry_with_backoff(
lambda: self.payment_provider.get_subscription(subscription_id),
max_retries=3,
exceptions=(Exception,) # Catch all exceptions for payment provider calls
)
return result
except Exception as e:
logger.error("Failed to get subscription from payment provider", error=str(e))
raise e
async def complete_subscription_after_setup_intent(
self,
setup_intent_id: str,
customer_id: str,
plan_id: str,
payment_method_id: str,
trial_period_days: Optional[int] = None
) -> Dict[str, Any]:
"""
Complete subscription creation after SetupIntent has been confirmed
This method is called after the frontend confirms a SetupIntent (with or without 3DS).
It verifies the SetupIntent and creates the subscription with the verified payment method.
Args:
setup_intent_id: The SetupIntent ID that was confirmed
customer_id: Payment provider customer ID
plan_id: Subscription plan ID
payment_method_id: Payment method ID
trial_period_days: Optional trial period in days
Returns:
Dictionary containing subscription details
"""
try:
logger.info("Completing subscription after SetupIntent via payment service",
setup_intent_id=setup_intent_id,
customer_id=customer_id,
plan_id=plan_id)
# Map plan ID to Stripe price ID (default to monthly)
stripe_price_id = self._get_stripe_price_id(plan_id, "monthly")
# Use retry logic for transient Stripe API failures
result = await retry_with_backoff(
lambda: self.payment_provider.complete_subscription_after_setup_intent(
setup_intent_id,
customer_id,
stripe_price_id,
payment_method_id,
trial_period_days
),
max_retries=3,
exceptions=(Exception,)
)
logger.info("Subscription completed successfully after SetupIntent",
setup_intent_id=setup_intent_id,
subscription_id=result['subscription'].id if 'subscription' in result else None)
return result
except Exception as e:
logger.error("Failed to complete subscription after SetupIntent in payment service",
error=str(e),
setup_intent_id=setup_intent_id,
customer_id=customer_id,
exc_info=True)
raise e
async def update_payment_subscription(
self,
subscription_id: str,
@@ -184,14 +314,20 @@ class PaymentService:
Updated Subscription object
"""
try:
return await self.payment_provider.update_subscription(
subscription_id,
new_price_id,
proration_behavior,
billing_cycle_anchor,
payment_behavior,
immediate_change
# Use retry logic for transient Stripe API failures
result = await retry_with_backoff(
lambda: self.payment_provider.update_subscription(
subscription_id,
new_price_id,
proration_behavior,
billing_cycle_anchor,
payment_behavior,
immediate_change
),
max_retries=3,
exceptions=(Exception,) # Catch all exceptions for payment provider calls
)
return result
except Exception as e:
logger.error("Failed to update subscription in payment provider", error=str(e))
raise e
@@ -214,11 +350,17 @@ class PaymentService:
Dictionary with proration details
"""
try:
return await self.payment_provider.calculate_proration(
subscription_id,
new_price_id,
proration_behavior
# Use retry logic for transient Stripe API failures
result = await retry_with_backoff(
lambda: self.payment_provider.calculate_proration(
subscription_id,
new_price_id,
proration_behavior
),
max_retries=3,
exceptions=(Exception,) # Catch all exceptions for payment provider calls
)
return result
except Exception as e:
logger.error("Failed to calculate proration", error=str(e))
raise e
@@ -241,11 +383,17 @@ class PaymentService:
Updated Subscription object
"""
try:
return await self.payment_provider.change_billing_cycle(
subscription_id,
new_billing_cycle,
proration_behavior
# Use retry logic for transient Stripe API failures
result = await retry_with_backoff(
lambda: self.payment_provider.change_billing_cycle(
subscription_id,
new_billing_cycle,
proration_behavior
),
max_retries=3,
exceptions=(Exception,) # Catch all exceptions for payment provider calls
)
return result
except Exception as e:
logger.error("Failed to change billing cycle", error=str(e))
raise e
@@ -264,8 +412,12 @@ class PaymentService:
List of invoice dictionaries
"""
try:
# Fetch invoices from payment provider
stripe_invoices = await self.payment_provider.get_invoices(customer_id)
# Use retry logic for transient Stripe API failures
stripe_invoices = await retry_with_backoff(
lambda: self.payment_provider.get_invoices(customer_id),
max_retries=3,
exceptions=(Exception,) # Catch all exceptions for payment provider calls
)
# Transform to response format
invoices = []
@@ -328,6 +480,28 @@ class PaymentService:
logger.error("Failed to verify webhook signature", error=str(e))
raise e
async def get_customer_payment_method(self, customer_id: str) -> Optional[PaymentMethod]:
"""
Get the current payment method for a customer
Args:
customer_id: Payment provider customer ID
Returns:
PaymentMethod object or None if no payment method exists
"""
try:
# Use retry logic for transient Stripe API failures
result = await retry_with_backoff(
lambda: self.payment_provider.get_customer_payment_method(customer_id),
max_retries=3,
exceptions=(Exception,) # Catch all exceptions for payment provider calls
)
return result
except Exception as e:
logger.error("Failed to get customer payment method", error=str(e), customer_id=customer_id)
return None
async def process_registration_with_subscription(
self,
user_data: Dict[str, Any],
@@ -338,20 +512,20 @@ class PaymentService:
) -> Dict[str, Any]:
"""
Process user registration with subscription creation
This method handles the complete flow:
1. Create payment customer (if not exists)
2. Attach payment method to customer
3. Create subscription with coupon/trial
4. Return subscription details
Args:
user_data: User data including email, name, etc.
plan_id: Subscription plan ID
payment_method_id: Payment method ID from frontend
coupon_code: Optional coupon code for discounts/trials
billing_interval: Billing interval (monthly/yearly)
Returns:
Dictionary with subscription and customer details
"""
@@ -361,7 +535,7 @@ class PaymentService:
logger.info("Payment customer created for registration",
customer_id=customer.id,
email=user_data.get('email'))
# Step 2: Attach payment method to customer
if payment_method_id:
try:
@@ -375,7 +549,7 @@ class PaymentService:
error=str(e))
# Continue without attached payment method - user can add it later
payment_method = None
# Step 3: Determine trial period from coupon
trial_period_days = None
if coupon_code:
@@ -391,7 +565,7 @@ class PaymentService:
# Other coupons might provide different trial periods
# This would be configured in your coupon system
trial_period_days = 30 # Default trial for other coupons
# Step 4: Create subscription
subscription = await self.create_payment_subscription(
customer.id,
@@ -400,13 +574,13 @@ class PaymentService:
trial_period_days,
billing_interval
)
logger.info("Subscription created successfully during registration",
subscription_id=subscription.id,
customer_id=customer.id,
plan_id=plan_id,
status=subscription.status)
# Step 5: Return comprehensive result
return {
"success": True,
@@ -434,10 +608,132 @@ class PaymentService:
"coupon_applied": coupon_code is not None,
"trial_active": trial_period_days is not None and trial_period_days > 0
}
except Exception as e:
logger.error("Failed to process registration with subscription",
error=str(e),
plan_id=plan_id,
customer_email=user_data.get('email'))
raise e
async def create_payment_customer(
self,
user_data: Dict[str, Any],
payment_method_id: Optional[str] = None
) -> Dict[str, Any]:
"""
Create payment customer (supports pre-user-creation flow)
This method creates a payment customer without requiring a user_id,
supporting the secure architecture where users are only created after
payment verification.
Args:
user_data: User data (email, full_name, etc.)
payment_method_id: Optional payment method ID
Returns:
Dictionary with payment customer creation result
"""
try:
# Create customer without user_id (for pre-user-creation flow)
customer_data = {
'email': user_data.get('email'),
'name': user_data.get('full_name'),
'metadata': {
'registration_flow': 'pre_user_creation',
'timestamp': datetime.now(timezone.utc).isoformat()
}
}
# Create customer in payment provider
customer = await retry_with_backoff(
lambda: self.payment_provider.create_customer(customer_data),
max_retries=3,
exceptions=(Exception,)
)
logger.info("Payment customer created for registration (pre-user creation)",
customer_id=customer.id,
email=user_data.get('email'))
# Optionally attach payment method if provided
payment_method = None
if payment_method_id:
try:
payment_method = await self.update_payment_method(
customer.id,
payment_method_id
)
logger.info("Payment method attached to customer (pre-user creation)",
customer_id=customer.id,
payment_method_id=payment_method.id)
except Exception as e:
logger.warning("Failed to attach payment method during pre-user creation",
customer_id=customer.id,
error=str(e))
# Continue without payment method - can be added later
return {
"success": True,
"payment_customer_id": customer.id,
"customer_id": customer.id,
"email": user_data.get('email'),
"payment_method_id": payment_method.id if payment_method else None,
"payment_method_type": payment_method.type if payment_method else None,
"payment_method_last4": payment_method.last4 if payment_method else None
}
except Exception as e:
logger.error("Failed to create payment customer for registration",
email=user_data.get('email'),
error=str(e),
exc_info=True)
raise
async def verify_setup_intent(
self,
setup_intent_id: str
) -> Dict[str, Any]:
"""
Verify SetupIntent status with payment provider
This method checks if a SetupIntent has been successfully confirmed
(either automatically or via 3DS authentication).
Args:
setup_intent_id: SetupIntent ID to verify
Returns:
Dictionary with SetupIntent verification result
"""
try:
# Retrieve SetupIntent from payment provider
setup_intent = await retry_with_backoff(
lambda: self.payment_provider.get_setup_intent(setup_intent_id),
max_retries=3,
exceptions=(Exception,)
)
logger.info("SetupIntent verification result",
setup_intent_id=setup_intent_id,
status=setup_intent.status)
return {
"success": True,
"setup_intent_id": setup_intent.id,
"status": setup_intent.status,
"customer_id": setup_intent.customer,
"payment_method_id": setup_intent.payment_method,
"created": setup_intent.created,
"last_setup_error": setup_intent.last_setup_error,
"next_action": setup_intent.next_action,
"usage": setup_intent.usage
}
except Exception as e:
logger.error("Failed to verify SetupIntent",
setup_intent_id=setup_intent_id,
error=str(e),
exc_info=True)
raise

View File

@@ -121,7 +121,7 @@ class SubscriptionOrchestrationService:
# Step 4: Create local subscription record
logger.info("Creating local subscription record",
tenant_id=tenant_id,
stripe_subscription_id=stripe_subscription.id)
subscription_id=stripe_subscription.id)
subscription_record = await self.subscription_service.create_subscription_record(
tenant_id,
@@ -141,7 +141,7 @@ class SubscriptionOrchestrationService:
tenant_id=tenant_id)
tenant_update_data = {
'stripe_customer_id': customer.id,
'customer_id': customer.id,
'subscription_status': stripe_subscription.status,
'subscription_plan': plan_id,
'subscription_tier': plan_id,
@@ -265,13 +265,13 @@ class SubscriptionOrchestrationService:
coupon_code=coupon_code,
error=error)
# Step 3: Create subscription in payment provider
# Step 3: Create subscription in payment provider (or get SetupIntent for 3DS)
logger.info("Creating subscription in payment provider",
customer_id=customer.id,
plan_id=plan_id,
trial_period_days=trial_period_days)
stripe_subscription = await self.payment_service.create_payment_subscription(
subscription_result = await self.payment_service.create_payment_subscription(
customer.id,
plan_id,
payment_method_id,
@@ -279,6 +279,35 @@ class SubscriptionOrchestrationService:
billing_interval
)
# Check if result requires 3DS authentication (SetupIntent confirmation)
if isinstance(subscription_result, dict) and subscription_result.get('requires_action'):
logger.info("Subscription creation requires SetupIntent confirmation",
customer_id=customer.id,
action_type=subscription_result.get('action_type'),
setup_intent_id=subscription_result.get('setup_intent_id'))
# Return the SetupIntent data for frontend to handle 3DS
return {
"requires_action": True,
"action_type": subscription_result.get('action_type'),
"client_secret": subscription_result.get('client_secret'),
"setup_intent_id": subscription_result.get('setup_intent_id'),
"customer_id": customer.id,
"payment_method_id": payment_method_id,
"plan_id": plan_id,
"trial_period_days": trial_period_days,
"billing_interval": billing_interval,
"message": subscription_result.get('message'),
"user_id": user_data.get('user_id')
}
# Extract subscription object from result
# Result can be either a dict with 'subscription' key or the subscription object directly
if isinstance(subscription_result, dict) and 'subscription' in subscription_result:
stripe_subscription = subscription_result['subscription']
else:
stripe_subscription = subscription_result
logger.info("Subscription created in payment provider",
subscription_id=stripe_subscription.id,
status=stripe_subscription.status)
@@ -286,7 +315,7 @@ class SubscriptionOrchestrationService:
# Step 4: Create local subscription record WITHOUT tenant_id
logger.info("Creating tenant-independent subscription record",
user_id=user_data.get('user_id'),
stripe_subscription_id=stripe_subscription.id)
subscription_id=stripe_subscription.id)
subscription_record = await self.subscription_service.create_tenant_independent_subscription_record(
stripe_subscription.id,
@@ -345,6 +374,100 @@ class SubscriptionOrchestrationService:
error=str(e), user_id=user_data.get('user_id'))
raise DatabaseError(f"Failed to create tenant-independent subscription: {str(e)}")
async def complete_subscription_after_setup_intent(
self,
setup_intent_id: str,
customer_id: str,
plan_id: str,
payment_method_id: str,
trial_period_days: Optional[int],
user_id: str,
billing_interval: str = "monthly"
) -> Dict[str, Any]:
"""
Complete subscription creation after SetupIntent has been confirmed
This method is called after the frontend successfully confirms a SetupIntent
(with or without 3DS). It creates the subscription with the verified payment method
and creates a database record.
Args:
setup_intent_id: The confirmed SetupIntent ID
customer_id: Stripe customer ID
plan_id: Subscription plan ID
payment_method_id: Verified payment method ID
trial_period_days: Optional trial period
user_id: User ID for linking
billing_interval: Billing interval
Returns:
Dictionary with subscription details
"""
try:
logger.info("Completing subscription after SetupIntent confirmation",
setup_intent_id=setup_intent_id,
user_id=user_id,
plan_id=plan_id)
# Call payment service to complete subscription creation
result = await self.payment_service.complete_subscription_after_setup_intent(
setup_intent_id,
customer_id,
plan_id,
payment_method_id,
trial_period_days
)
stripe_subscription = result['subscription']
logger.info("Subscription created in payment provider after SetupIntent",
subscription_id=stripe_subscription.id,
status=stripe_subscription.status)
# Create local subscription record WITHOUT tenant_id (tenant-independent)
subscription_record = await self.subscription_service.create_tenant_independent_subscription_record(
stripe_subscription.id,
customer_id,
plan_id,
stripe_subscription.status,
trial_period_days,
billing_interval,
user_id
)
logger.info("Tenant-independent subscription record created after SetupIntent",
subscription_id=stripe_subscription.id,
user_id=user_id)
# Convert current_period_end to ISO format
current_period_end = stripe_subscription.current_period_end
if isinstance(current_period_end, int):
current_period_end = datetime.fromtimestamp(current_period_end, tz=timezone.utc).isoformat()
elif hasattr(current_period_end, 'isoformat'):
current_period_end = current_period_end.isoformat()
else:
current_period_end = str(current_period_end)
return {
"success": True,
"customer_id": customer_id,
"subscription_id": stripe_subscription.id,
"status": stripe_subscription.status,
"plan": plan_id,
"billing_cycle": billing_interval,
"trial_period_days": trial_period_days,
"current_period_end": current_period_end,
"user_id": user_id,
"setup_intent_id": setup_intent_id
}
except Exception as e:
logger.error("Failed to complete subscription after SetupIntent",
error=str(e),
setup_intent_id=setup_intent_id,
user_id=user_id)
raise DatabaseError(f"Failed to complete subscription: {str(e)}")
async def orchestrate_subscription_cancellation(
self,
tenant_id: str,
@@ -383,7 +506,7 @@ class SubscriptionOrchestrationService:
)
logger.info("Subscription cancelled in payment provider",
stripe_subscription_id=stripe_subscription.id,
subscription_id=stripe_subscription.id,
stripe_status=stripe_subscription.status)
# Step 4: Sync status back to database
@@ -536,7 +659,7 @@ class SubscriptionOrchestrationService:
)
logger.info("Plan updated in payment provider",
stripe_subscription_id=updated_stripe_subscription.id,
subscription_id=updated_stripe_subscription.id,
new_status=updated_stripe_subscription.status)
# Step 5: Update local subscription record
@@ -622,7 +745,7 @@ class SubscriptionOrchestrationService:
)
logger.info("Billing cycle changed in payment provider",
stripe_subscription_id=updated_stripe_subscription.id,
subscription_id=updated_stripe_subscription.id,
new_billing_cycle=new_billing_cycle)
# Step 3: Get proration details (if available)
@@ -771,6 +894,26 @@ class SubscriptionOrchestrationService:
await self._handle_subscription_resumed(event_data)
result["actions_taken"].append("subscription_resumed")
elif event_type == 'payment_intent.succeeded':
await self._handle_payment_intent_succeeded(event_data)
result["actions_taken"].append("payment_intent_succeeded")
elif event_type == 'payment_intent.payment_failed':
await self._handle_payment_intent_failed(event_data)
result["actions_taken"].append("payment_intent_failed")
elif event_type == 'payment_intent.requires_action':
await self._handle_payment_intent_requires_action(event_data)
result["actions_taken"].append("payment_intent_requires_action")
elif event_type == 'setup_intent.succeeded':
await self._handle_setup_intent_succeeded(event_data)
result["actions_taken"].append("setup_intent_succeeded")
elif event_type == 'setup_intent.requires_action':
await self._handle_setup_intent_requires_action(event_data)
result["actions_taken"].append("setup_intent_requires_action")
else:
logger.info("Unhandled webhook event type", event_type=event_type)
result["processed"] = False
@@ -800,7 +943,7 @@ class SubscriptionOrchestrationService:
status=status)
# Find tenant by customer ID
tenant = await self.tenant_service.get_tenant_by_stripe_customer_id(customer_id)
tenant = await self.tenant_service.get_tenant_by_customer_id(customer_id)
if tenant:
# Update subscription status
@@ -896,7 +1039,7 @@ class SubscriptionOrchestrationService:
customer_id=customer_id)
# Find tenant and update payment status
tenant = await self.tenant_service.get_tenant_by_stripe_customer_id(customer_id)
tenant = await self.tenant_service.get_tenant_by_customer_id(customer_id)
if tenant:
tenant_update_data = {
@@ -924,7 +1067,7 @@ class SubscriptionOrchestrationService:
customer_id=customer_id)
# Find tenant and update payment status
tenant = await self.tenant_service.get_tenant_by_stripe_customer_id(customer_id)
tenant = await self.tenant_service.get_tenant_by_customer_id(customer_id)
if tenant:
tenant_update_data = {
@@ -951,7 +1094,7 @@ class SubscriptionOrchestrationService:
customer_id=customer_id,
trial_end=trial_end)
tenant = await self.tenant_service.get_tenant_by_stripe_customer_id(customer_id)
tenant = await self.tenant_service.get_tenant_by_customer_id(customer_id)
if tenant:
tenant_update_data = {
@@ -978,7 +1121,7 @@ class SubscriptionOrchestrationService:
customer_id=customer_id,
subscription_id=subscription_id)
tenant = await self.tenant_service.get_tenant_by_stripe_customer_id(customer_id)
tenant = await self.tenant_service.get_tenant_by_customer_id(customer_id)
if tenant:
tenant_update_data = {
@@ -1004,7 +1147,7 @@ class SubscriptionOrchestrationService:
subscription_id=subscription_id,
customer_id=customer_id)
tenant = await self.tenant_service.get_tenant_by_stripe_customer_id(customer_id)
tenant = await self.tenant_service.get_tenant_by_customer_id(customer_id)
if tenant:
await self.subscription_service.update_subscription_status(
@@ -1038,7 +1181,7 @@ class SubscriptionOrchestrationService:
subscription_id=subscription_id,
customer_id=customer_id)
tenant = await self.tenant_service.get_tenant_by_stripe_customer_id(customer_id)
tenant = await self.tenant_service.get_tenant_by_customer_id(customer_id)
if tenant:
await self.subscription_service.update_subscription_status(
@@ -1062,6 +1205,155 @@ class SubscriptionOrchestrationService:
tenant_id=str(tenant.id),
subscription_id=subscription_id)
async def _handle_payment_intent_succeeded(self, event_data: Dict[str, Any]):
"""Handle payment intent succeeded event (including 3DS authenticated payments)"""
payment_intent_id = event_data['id']
customer_id = event_data.get('customer')
amount = event_data.get('amount', 0) / 100.0
currency = event_data.get('currency', 'eur').upper()
logger.info("Handling payment intent succeeded event",
payment_intent_id=payment_intent_id,
customer_id=customer_id,
amount=amount)
if customer_id:
tenant = await self.tenant_service.get_tenant_by_customer_id(customer_id)
if tenant:
tenant_update_data = {
'payment_action_required': False,
'last_successful_payment_at': datetime.now(timezone.utc)
}
await self.tenant_service.update_tenant_subscription_info(
str(tenant.id), tenant_update_data
)
logger.info("Payment intent succeeded event handled",
tenant_id=str(tenant.id),
payment_intent_id=payment_intent_id)
async def _handle_payment_intent_failed(self, event_data: Dict[str, Any]):
"""Handle payment intent failed event (including 3DS authentication failures)"""
payment_intent_id = event_data['id']
customer_id = event_data.get('customer')
last_payment_error = event_data.get('last_payment_error', {})
error_message = last_payment_error.get('message', 'Payment failed')
logger.warning("Handling payment intent failed event",
payment_intent_id=payment_intent_id,
customer_id=customer_id,
error_message=error_message)
if customer_id:
tenant = await self.tenant_service.get_tenant_by_customer_id(customer_id)
if tenant:
tenant_update_data = {
'payment_action_required': False,
'last_payment_failure_at': datetime.now(timezone.utc),
'last_payment_error': error_message
}
await self.tenant_service.update_tenant_subscription_info(
str(tenant.id), tenant_update_data
)
logger.info("Payment intent failed event handled",
tenant_id=str(tenant.id),
payment_intent_id=payment_intent_id)
async def _handle_payment_intent_requires_action(self, event_data: Dict[str, Any]):
"""Handle payment intent requires action event (3DS authentication needed)"""
payment_intent_id = event_data['id']
customer_id = event_data.get('customer')
next_action = event_data.get('next_action', {})
action_type = next_action.get('type', 'unknown')
logger.info("Handling payment intent requires action event",
payment_intent_id=payment_intent_id,
customer_id=customer_id,
action_type=action_type)
if customer_id:
tenant = await self.tenant_service.get_tenant_by_customer_id(customer_id)
if tenant:
tenant_update_data = {
'payment_action_required': True,
'payment_action_type': action_type,
'last_payment_action_required_at': datetime.now(timezone.utc)
}
await self.tenant_service.update_tenant_subscription_info(
str(tenant.id), tenant_update_data
)
logger.info("Payment intent requires action event handled",
tenant_id=str(tenant.id),
payment_intent_id=payment_intent_id,
action_type=action_type)
async def _handle_setup_intent_succeeded(self, event_data: Dict[str, Any]):
"""Handle setup intent succeeded event (3DS authentication completed)"""
setup_intent_id = event_data['id']
customer_id = event_data.get('customer')
logger.info("Handling setup intent succeeded event (3DS authentication completed)",
setup_intent_id=setup_intent_id,
customer_id=customer_id)
if customer_id:
tenant = await self.tenant_service.get_tenant_by_customer_id(customer_id)
if tenant:
tenant_update_data = {
'threeds_authentication_completed': True,
'threeds_authentication_completed_at': datetime.now(timezone.utc),
'last_threeds_setup_intent_id': setup_intent_id
}
await self.tenant_service.update_tenant_subscription_info(
str(tenant.id), tenant_update_data
)
logger.info("Setup intent succeeded event handled (3DS authentication completed)",
tenant_id=str(tenant.id),
setup_intent_id=setup_intent_id)
async def _handle_setup_intent_requires_action(self, event_data: Dict[str, Any]):
"""Handle setup intent requires action event (3DS authentication needed)"""
setup_intent_id = event_data['id']
customer_id = event_data.get('customer')
next_action = event_data.get('next_action', {})
action_type = next_action.get('type', 'unknown')
logger.info("Handling setup intent requires action event (3DS authentication needed)",
setup_intent_id=setup_intent_id,
customer_id=customer_id,
action_type=action_type)
if customer_id:
tenant = await self.tenant_service.get_tenant_by_customer_id(customer_id)
if tenant:
tenant_update_data = {
'threeds_authentication_required': True,
'threeds_authentication_required_at': datetime.now(timezone.utc),
'last_threeds_setup_intent_id': setup_intent_id,
'threeds_action_type': action_type
}
await self.tenant_service.update_tenant_subscription_info(
str(tenant.id), tenant_update_data
)
logger.info("Setup intent requires action event handled (3DS authentication needed)",
tenant_id=str(tenant.id),
setup_intent_id=setup_intent_id,
action_type=action_type)
async def orchestrate_subscription_creation_with_default_payment(
self,
tenant_id: str,
@@ -1165,3 +1457,310 @@ class SubscriptionOrchestrationService:
error=str(e))
# Don't fail the subscription creation if we can't get the default payment method
return None
async def get_payment_method(self, tenant_id: str) -> Optional[Dict[str, Any]]:
"""
Get the current payment method for a tenant's subscription
This is an orchestration method that coordinates between:
1. SubscriptionService (to get subscription data)
2. PaymentService (to get payment method from provider)
Args:
tenant_id: Tenant ID
Returns:
Dictionary with payment method details or None
"""
try:
# Get subscription from database
subscription = await self.subscription_service.get_subscription_by_tenant_id(tenant_id)
if not subscription:
logger.warning("get_payment_method_no_subscription",
tenant_id=tenant_id)
return None
# Check if subscription has a customer ID
if not subscription.customer_id:
logger.warning("get_payment_method_no_customer_id",
tenant_id=tenant_id)
return None
# Get payment method from payment provider
payment_method = await self.payment_service.get_customer_payment_method(subscription.customer_id)
if not payment_method:
logger.info("get_payment_method_not_found",
tenant_id=tenant_id,
customer_id=subscription.customer_id)
return None
logger.info("payment_method_retrieved",
tenant_id=tenant_id,
payment_method_type=payment_method.type,
last4=payment_method.last4)
return {
"brand": payment_method.brand,
"last4": payment_method.last4,
"exp_month": payment_method.exp_month,
"exp_year": payment_method.exp_year
}
except Exception as e:
logger.error("get_payment_method_failed",
error=str(e),
tenant_id=tenant_id)
return None
async def update_payment_method(
self,
tenant_id: str,
payment_method_id: str
) -> Dict[str, Any]:
"""
Update the default payment method for a tenant's subscription
This is an orchestration method that coordinates between:
1. SubscriptionService (to get subscription data)
2. PaymentService (to update payment method with provider)
Args:
tenant_id: Tenant ID
payment_method_id: New payment method ID from frontend
Returns:
Dictionary with updated payment method details
Raises:
ValidationError: If subscription or customer_id not found
"""
try:
# Get subscription from database
subscription = await self.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.customer_id:
raise ValidationError(f"Tenant {tenant_id} does not have a payment customer ID")
# Update payment method via payment provider
payment_result = await self.payment_service.update_payment_method(
subscription.customer_id,
payment_method_id
)
logger.info("payment_method_updated",
tenant_id=tenant_id,
payment_method_id=payment_method_id,
requires_action=payment_result.get('requires_action', False))
pm_details = payment_result.get('payment_method', {})
return {
"success": True,
"message": "Payment method updated successfully",
"payment_method_id": pm_details.get('id'),
"brand": pm_details.get('brand', 'unknown'),
"last4": pm_details.get('last4', '0000'),
"exp_month": pm_details.get('exp_month'),
"exp_year": pm_details.get('exp_year'),
"requires_action": payment_result.get('requires_action', False),
"client_secret": payment_result.get('client_secret'),
"payment_intent_status": payment_result.get('payment_intent_status')
}
except ValidationError:
raise
except Exception as e:
logger.error("update_payment_method_failed",
error=str(e),
tenant_id=tenant_id)
raise DatabaseError(f"Failed to update payment method: {str(e)}")
async def create_registration_payment_setup(
self,
user_data: Dict[str, Any],
plan_id: str,
payment_method_id: str,
billing_interval: str = "monthly",
coupon_code: Optional[str] = None
) -> Dict[str, Any]:
"""
Create payment customer and SetupIntent for registration (pre-user-creation)
This method supports the secure architecture where users are only created
after payment verification. It creates a payment customer and SetupIntent
without requiring a user_id.
Args:
user_data: User data (email, full_name, etc.) - NO user_id required
plan_id: Subscription plan ID
payment_method_id: Payment method ID from frontend
billing_interval: Billing interval (monthly/yearly)
coupon_code: Optional coupon code
Returns:
Dictionary with payment setup results including SetupIntent if required
Raises:
Exception: If payment setup fails
"""
try:
logger.info("Starting registration payment setup (pre-user-creation)",
email=user_data.get('email'),
plan_id=plan_id)
# Step 1: Create payment customer (without user_id)
logger.info("Creating payment customer for registration",
email=user_data.get('email'))
# Create customer without user_id metadata
customer_data = {
'email': user_data.get('email'),
'name': user_data.get('full_name'),
'metadata': {
'registration_flow': 'pre_user_creation',
'timestamp': datetime.now(timezone.utc).isoformat()
}
}
customer = await self.payment_service.create_customer(customer_data)
logger.info("Payment customer created for registration",
customer_id=customer.id,
email=user_data.get('email'))
# Step 2: Handle coupon logic (if provided)
trial_period_days = 0
coupon_discount = None
if coupon_code:
logger.info("Validating and redeeming coupon code for registration",
coupon_code=coupon_code,
email=user_data.get('email'))
coupon_service = CouponService(self.db_session)
success, discount_applied, error = await coupon_service.redeem_coupon(
coupon_code,
None, # No tenant_id yet
base_trial_days=0
)
if success and discount_applied:
coupon_discount = discount_applied
trial_period_days = discount_applied.get("total_trial_days", 0)
logger.info("Coupon redeemed successfully for registration",
coupon_code=coupon_code,
trial_period_days=trial_period_days)
else:
logger.warning("Failed to redeem coupon for registration, continuing without it",
coupon_code=coupon_code,
error=error)
# Step 3: Create subscription/SetupIntent
logger.info("Creating subscription/SetupIntent for registration",
customer_id=customer.id,
plan_id=plan_id,
payment_method_id=payment_method_id)
subscription_result = await self.payment_service.create_payment_subscription(
customer.id,
plan_id,
payment_method_id,
trial_period_days if trial_period_days > 0 else None,
billing_interval
)
# Check if result requires 3DS authentication (SetupIntent confirmation)
if isinstance(subscription_result, dict) and subscription_result.get('requires_action'):
logger.info("Registration payment setup requires SetupIntent confirmation",
customer_id=customer.id,
action_type=subscription_result.get('action_type'),
setup_intent_id=subscription_result.get('setup_intent_id'))
# Return the SetupIntent data for frontend to handle 3DS
return {
"requires_action": True,
"action_type": subscription_result.get('action_type'),
"client_secret": subscription_result.get('client_secret'),
"setup_intent_id": subscription_result.get('setup_intent_id'),
"customer_id": customer.id,
"payment_customer_id": customer.id,
"plan_id": plan_id,
"payment_method_id": payment_method_id,
"trial_period_days": trial_period_days,
"billing_interval": billing_interval,
"coupon_applied": coupon_code is not None,
"email": user_data.get('email'),
"full_name": user_data.get('full_name'),
"message": subscription_result.get('message') or "Payment verification required before account creation"
}
else:
# No 3DS required - subscription created successfully
logger.info("Registration payment setup completed without 3DS",
customer_id=customer.id,
subscription_id=subscription_result.get('subscription_id'))
return {
"requires_action": False,
"subscription_id": subscription_result.get('subscription_id'),
"customer_id": customer.id,
"payment_customer_id": customer.id,
"plan_id": plan_id,
"payment_method_id": payment_method_id,
"trial_period_days": trial_period_days,
"billing_interval": billing_interval,
"coupon_applied": coupon_code is not None,
"email": user_data.get('email'),
"full_name": user_data.get('full_name'),
"message": "Payment setup completed successfully"
}
except Exception as e:
logger.error("Registration payment setup failed",
email=user_data.get('email'),
error=str(e),
exc_info=True)
raise
async def verify_setup_intent_for_registration(
self,
setup_intent_id: str
) -> Dict[str, Any]:
"""
Verify SetupIntent status for registration completion
This method checks if a SetupIntent has been successfully confirmed
(either automatically or via 3DS authentication) before proceeding
with user creation.
Args:
setup_intent_id: SetupIntent ID to verify
Returns:
Dictionary with SetupIntent verification result
Raises:
Exception: If verification fails
"""
try:
logger.info("Verifying SetupIntent for registration completion",
setup_intent_id=setup_intent_id)
# Use payment service to verify SetupIntent
verification_result = await self.payment_service.verify_setup_intent(setup_intent_id)
logger.info("SetupIntent verification result for registration",
setup_intent_id=setup_intent_id,
status=verification_result.get('status'))
return verification_result
except Exception as e:
logger.error("SetupIntent verification failed for registration",
setup_intent_id=setup_intent_id,
error=str(e),
exc_info=True)
raise

View File

@@ -30,8 +30,8 @@ class SubscriptionService:
async def create_subscription_record(
self,
tenant_id: str,
stripe_subscription_id: str,
stripe_customer_id: str,
subscription_id: str,
customer_id: str,
plan: str,
status: str,
trial_period_days: Optional[int] = None,
@@ -42,8 +42,8 @@ class SubscriptionService:
Args:
tenant_id: Tenant ID
stripe_subscription_id: Stripe subscription ID
stripe_customer_id: Stripe customer ID
subscription_id: Payment provider subscription ID
customer_id: Payment provider customer ID
plan: Subscription plan
status: Subscription status
trial_period_days: Optional trial period in days
@@ -66,8 +66,8 @@ class SubscriptionService:
# Create local subscription record
subscription_data = {
'tenant_id': str(tenant_id),
'subscription_id': stripe_subscription_id, # Stripe subscription ID
'customer_id': stripe_customer_id, # Stripe customer ID
'subscription_id': subscription_id,
'customer_id': customer_id,
'plan_id': plan,
'status': status,
'created_at': datetime.now(timezone.utc),
@@ -79,7 +79,7 @@ class SubscriptionService:
logger.info("subscription_record_created",
tenant_id=tenant_id,
subscription_id=stripe_subscription_id,
subscription_id=subscription_id,
plan=plan)
return created_subscription
@@ -181,24 +181,24 @@ class SubscriptionService:
error=str(e), tenant_id=tenant_id)
return None
async def get_subscription_by_stripe_id(
async def get_subscription_by_provider_id(
self,
stripe_subscription_id: str
subscription_id: str
) -> Optional[Subscription]:
"""
Get subscription by Stripe subscription ID
Get subscription by payment provider subscription ID
Args:
stripe_subscription_id: Stripe subscription ID
subscription_id: Payment provider subscription ID
Returns:
Subscription object or None
"""
try:
return await self.subscription_repo.get_by_stripe_id(stripe_subscription_id)
return await self.subscription_repo.get_by_provider_id(subscription_id)
except Exception as e:
logger.error("get_subscription_by_stripe_id_failed",
error=str(e), stripe_subscription_id=stripe_subscription_id)
logger.error("get_subscription_by_provider_id_failed",
error=str(e), subscription_id=subscription_id)
return None
async def cancel_subscription(
@@ -587,8 +587,8 @@ class SubscriptionService:
async def create_tenant_independent_subscription_record(
self,
stripe_subscription_id: str,
stripe_customer_id: str,
subscription_id: str,
customer_id: str,
plan: str,
status: str,
trial_period_days: Optional[int] = None,
@@ -601,8 +601,8 @@ class SubscriptionService:
This subscription is not linked to any tenant and will be linked during onboarding
Args:
stripe_subscription_id: Stripe subscription ID
stripe_customer_id: Stripe customer ID
subscription_id: Payment provider subscription ID
customer_id: Payment provider customer ID
plan: Subscription plan
status: Subscription status
trial_period_days: Optional trial period in days
@@ -615,8 +615,8 @@ class SubscriptionService:
try:
# Create tenant-independent subscription record
subscription_data = {
'stripe_subscription_id': stripe_subscription_id, # Stripe subscription ID
'stripe_customer_id': stripe_customer_id, # Stripe customer ID
'subscription_id': subscription_id,
'customer_id': customer_id,
'plan': plan, # Repository expects 'plan', not 'plan_id'
'status': status,
'created_at': datetime.now(timezone.utc),
@@ -630,7 +630,7 @@ class SubscriptionService:
created_subscription = await self.subscription_repo.create_tenant_independent_subscription(subscription_data)
logger.info("tenant_independent_subscription_record_created",
subscription_id=stripe_subscription_id,
subscription_id=subscription_id,
user_id=user_id,
plan=plan)

View File

@@ -1445,7 +1445,7 @@ class EnhancedTenantService:
# Update tenant with subscription information
tenant_update = {
"stripe_customer_id": subscription.customer_id,
"customer_id": subscription.customer_id,
"subscription_status": subscription.status,
"subscription_plan": subscription.plan,
"subscription_tier": subscription.plan,