Add subcription feature 2
This commit is contained in:
@@ -228,8 +228,8 @@ CREATE TABLE tenants (
|
||||
|
||||
-- Subscription
|
||||
subscription_tier VARCHAR(50) DEFAULT 'free', -- free, pro, enterprise
|
||||
stripe_customer_id VARCHAR(255), -- Stripe customer ID
|
||||
stripe_subscription_id VARCHAR(255), -- Stripe subscription ID
|
||||
customer_id VARCHAR(255), -- Stripe customer ID
|
||||
subscription_id VARCHAR(255), -- Stripe subscription ID
|
||||
|
||||
-- 🆕 Enterprise hierarchy fields (NEW)
|
||||
parent_tenant_id UUID REFERENCES tenants(id) ON DELETE RESTRICT,
|
||||
@@ -271,8 +271,8 @@ CREATE INDEX idx_tenants_hierarchy_path ON tenants(hierarchy_path);
|
||||
CREATE TABLE tenant_subscriptions (
|
||||
id UUID PRIMARY KEY,
|
||||
tenant_id UUID REFERENCES tenants(id) ON DELETE CASCADE,
|
||||
stripe_subscription_id VARCHAR(255) UNIQUE,
|
||||
stripe_customer_id VARCHAR(255),
|
||||
subscription_id VARCHAR(255) UNIQUE,
|
||||
customer_id VARCHAR(255),
|
||||
|
||||
-- Plan details
|
||||
plan_tier VARCHAR(50) NOT NULL, -- free, pro, enterprise
|
||||
@@ -486,7 +486,7 @@ CREATE TABLE tenant_audit_log (
|
||||
```sql
|
||||
CREATE INDEX idx_tenants_status ON tenants(status);
|
||||
CREATE INDEX idx_tenants_subscription_tier ON tenants(subscription_tier);
|
||||
CREATE INDEX idx_subscriptions_stripe ON tenant_subscriptions(stripe_subscription_id);
|
||||
CREATE INDEX idx_subscriptions_stripe ON tenant_subscriptions(subscription_id);
|
||||
CREATE INDEX idx_subscriptions_status ON tenant_subscriptions(tenant_id, status);
|
||||
CREATE INDEX idx_members_tenant ON tenant_members(tenant_id);
|
||||
CREATE INDEX idx_members_user ON tenant_members(user_id);
|
||||
@@ -555,7 +555,7 @@ async def create_tenant_with_subscription(
|
||||
}] if tenant.tax_id else None
|
||||
)
|
||||
|
||||
tenant.stripe_customer_id = stripe_customer.id
|
||||
tenant.customer_id = stripe_customer.id
|
||||
|
||||
# Attach payment method if provided
|
||||
if payment_method_id:
|
||||
@@ -587,13 +587,13 @@ async def create_tenant_with_subscription(
|
||||
|
||||
stripe_subscription = stripe.Subscription.create(**subscription_params)
|
||||
|
||||
tenant.stripe_subscription_id = stripe_subscription.id
|
||||
tenant.subscription_id = stripe_subscription.id
|
||||
|
||||
# Create subscription record
|
||||
subscription = TenantSubscription(
|
||||
tenant_id=tenant.id,
|
||||
stripe_subscription_id=stripe_subscription.id,
|
||||
stripe_customer_id=stripe_customer.id,
|
||||
subscription_id=stripe_subscription.id,
|
||||
customer_id=stripe_customer.id,
|
||||
plan_tier=plan_tier,
|
||||
plan_interval='month',
|
||||
plan_amount=get_plan_amount(plan_tier),
|
||||
@@ -705,11 +705,11 @@ async def update_subscription(
|
||||
new_price_id = get_stripe_price_id(new_plan_tier, subscription.plan_interval)
|
||||
|
||||
# Update Stripe subscription
|
||||
stripe_subscription = stripe.Subscription.retrieve(subscription.stripe_subscription_id)
|
||||
stripe_subscription = stripe.Subscription.retrieve(subscription.subscription_id)
|
||||
|
||||
# Update subscription items (Stripe handles proration automatically)
|
||||
stripe_subscription = stripe.Subscription.modify(
|
||||
subscription.stripe_subscription_id,
|
||||
subscription.subscription_id,
|
||||
items=[{
|
||||
'id': stripe_subscription['items']['data'][0].id,
|
||||
'price': new_price_id
|
||||
@@ -828,7 +828,7 @@ async def handle_subscription_updated(stripe_subscription: dict):
|
||||
tenant_id = UUID(stripe_subscription['metadata'].get('tenant_id'))
|
||||
|
||||
subscription = await db.query(TenantSubscription).filter(
|
||||
TenantSubscription.stripe_subscription_id == stripe_subscription['id']
|
||||
TenantSubscription.subscription_id == stripe_subscription['id']
|
||||
).first()
|
||||
|
||||
if subscription:
|
||||
@@ -846,7 +846,7 @@ async def handle_payment_failed(stripe_invoice: dict):
|
||||
customer_id = stripe_invoice['customer']
|
||||
|
||||
tenant = await db.query(Tenant).filter(
|
||||
Tenant.stripe_customer_id == customer_id
|
||||
Tenant.customer_id == customer_id
|
||||
).first()
|
||||
|
||||
if tenant:
|
||||
@@ -923,9 +923,9 @@ async def upgrade_tenant_to_enterprise(
|
||||
import stripe
|
||||
stripe.api_key = os.getenv('STRIPE_SECRET_KEY')
|
||||
|
||||
stripe_subscription = stripe.Subscription.retrieve(subscription.stripe_subscription_id)
|
||||
stripe_subscription = stripe.Subscription.retrieve(subscription.subscription_id)
|
||||
stripe.Subscription.modify(
|
||||
subscription.stripe_subscription_id,
|
||||
subscription.subscription_id,
|
||||
items=[{
|
||||
'id': stripe_subscription['items']['data'][0].id,
|
||||
'price': new_price_id
|
||||
@@ -1052,8 +1052,8 @@ async def add_child_outlet_to_parent(
|
||||
# 3. Create linked subscription (child shares parent subscription)
|
||||
child_subscription = TenantSubscription(
|
||||
tenant_id=child_tenant.id,
|
||||
stripe_subscription_id=None, # Linked to parent, no separate billing
|
||||
stripe_customer_id=parent.stripe_customer_id, # Same customer
|
||||
subscription_id=None, # Linked to parent, no separate billing
|
||||
customer_id=parent.customer_id, # Same customer
|
||||
plan_tier='enterprise',
|
||||
plan_interval='month',
|
||||
plan_amount=Decimal('0.00'), # No additional charge
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"]:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -204,8 +204,8 @@ def upgrade() -> None:
|
||||
sa.Column('trial_ends_at', sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column('cancelled_at', sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column('cancellation_effective_date', sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column('stripe_subscription_id', sa.String(255), nullable=True),
|
||||
sa.Column('stripe_customer_id', sa.String(255), nullable=True),
|
||||
sa.Column('subscription_id', sa.String(255), nullable=True),
|
||||
sa.Column('customer_id', sa.String(255), nullable=True),
|
||||
# Basic resource limits
|
||||
sa.Column('max_users', sa.Integer(), nullable=True),
|
||||
sa.Column('max_locations', sa.Integer(), nullable=True),
|
||||
@@ -284,24 +284,24 @@ def upgrade() -> None:
|
||||
WHERE status = 'active'
|
||||
""")
|
||||
|
||||
# Index 5: Stripe subscription lookup (for webhook processing)
|
||||
if not _index_exists(connection, 'idx_subscriptions_stripe_sub_id'):
|
||||
# Index 5: Subscription ID lookup (for webhook processing)
|
||||
if not _index_exists(connection, 'idx_subscriptions_subscription_id'):
|
||||
op.create_index(
|
||||
'idx_subscriptions_stripe_sub_id',
|
||||
'idx_subscriptions_subscription_id',
|
||||
'subscriptions',
|
||||
['stripe_subscription_id'],
|
||||
['subscription_id'],
|
||||
unique=False,
|
||||
postgresql_where=sa.text("stripe_subscription_id IS NOT NULL")
|
||||
postgresql_where=sa.text("subscription_id IS NOT NULL")
|
||||
)
|
||||
|
||||
# Index 6: Stripe customer lookup (for customer-related operations)
|
||||
if not _index_exists(connection, 'idx_subscriptions_stripe_customer_id'):
|
||||
# Index 6: Customer ID lookup (for customer-related operations)
|
||||
if not _index_exists(connection, 'idx_subscriptions_customer_id'):
|
||||
op.create_index(
|
||||
'idx_subscriptions_stripe_customer_id',
|
||||
'idx_subscriptions_customer_id',
|
||||
'subscriptions',
|
||||
['stripe_customer_id'],
|
||||
['customer_id'],
|
||||
unique=False,
|
||||
postgresql_where=sa.text("stripe_customer_id IS NOT NULL")
|
||||
postgresql_where=sa.text("customer_id IS NOT NULL")
|
||||
)
|
||||
|
||||
# Index 7: User ID for tenant linking
|
||||
@@ -481,8 +481,8 @@ def downgrade() -> None:
|
||||
# Drop subscriptions table indexes first
|
||||
op.drop_index('idx_subscriptions_linking_status', table_name='subscriptions')
|
||||
op.drop_index('idx_subscriptions_user_id', table_name='subscriptions')
|
||||
op.drop_index('idx_subscriptions_stripe_customer_id', table_name='subscriptions')
|
||||
op.drop_index('idx_subscriptions_stripe_sub_id', table_name='subscriptions')
|
||||
op.drop_index('idx_subscriptions_customer_id', table_name='subscriptions')
|
||||
op.drop_index('idx_subscriptions_subscription_id', table_name='subscriptions')
|
||||
op.drop_index('idx_subscriptions_active_tenant', table_name='subscriptions')
|
||||
op.drop_index('idx_subscriptions_status_billing', table_name='subscriptions')
|
||||
op.drop_index('idx_subscriptions_tenant_covering', table_name='subscriptions')
|
||||
|
||||
Reference in New Issue
Block a user