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

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