From 483a9f64cd580c0a820bd42008c5e2ddc7d97c76 Mon Sep 17 00:00:00 2001 From: Urtzi Alfaro Date: Thu, 15 Jan 2026 22:06:36 +0100 Subject: [PATCH] Add subcription feature 4 --- REARCHITECTURE_PROPOSAL.md | 846 ------------------ frontend/src/api/types/auth.ts | 4 + .../components/domain/auth/PaymentStep.tsx | 7 +- .../domain/auth/hooks/useRegistrationState.ts | 1 + services/auth/app/services/auth_service.py | 83 +- services/tenant/app/api/subscription.py | 491 ++++------ .../tenant/app/services/payment_service.py | 419 ++++++++- .../subscription_orchestration_service.py | 330 ++++--- shared/clients/stripe_client.py | 408 ++++++++- shared/exceptions/payment_exceptions.py | 10 + 10 files changed, 1209 insertions(+), 1390 deletions(-) delete mode 100644 REARCHITECTURE_PROPOSAL.md diff --git a/REARCHITECTURE_PROPOSAL.md b/REARCHITECTURE_PROPOSAL.md deleted file mode 100644 index eadd2c61..00000000 --- a/REARCHITECTURE_PROPOSAL.md +++ /dev/null @@ -1,846 +0,0 @@ -# User Registration & Subscription Architecture Rearchitecture Proposal - -## Executive Summary - -This proposal outlines a comprehensive rearchitecture of the user registration, payment processing, and subscription management flow to address the current limitations and implement the requested multi-phase registration process. - -## Current Architecture Analysis - -### Current Flow Limitations - -1. **Monolithic Registration Process**: The current flow combines user creation, payment processing, and subscription creation in a single step -2. **Tenant-Subscription Coupling**: Subscriptions are created and immediately linked to tenants during registration -3. **Payment Processing Timing**: Payment is processed before user creation is complete -4. **Onboarding Complexity**: The onboarding flow assumes immediate tenant creation with subscription - -### Key Components Analysis - -#### Frontend Components -- `RegisterForm.tsx`: Multi-step form handling basic info, subscription selection, and payment -- `PaymentForm.tsx`: Stripe payment processing component -- `RegisterTenantStep.tsx`: Tenant creation during onboarding - -#### Backend Services -- **Auth Service**: User creation, authentication, and onboarding progress tracking -- **Tenant Service**: Tenant creation, subscription management, and payment processing -- **Shared Clients**: Inter-service communication between auth and tenant services - -#### Current Data Flow - -```mermaid -graph TD - A[Frontend RegisterForm] -->|User Data + Payment| B[Auth Service Register] - B -->|Create User| C[User Created] - B -->|Call Tenant Service| D[Tenant Service Payment Customer] - D -->|Create Payment Customer| E[Payment Customer Created] - C -->|Return Tokens| F[User Authenticated] - F -->|Onboarding| G[RegisterTenantStep] - G -->|Create Tenant + Subscription| H[Tenant Service Create Tenant] - H -->|Create Subscription| I[Subscription Created] -``` - -## Proposed Architecture - -### New Multi-Phase Registration Flow - -```mermaid -graph TD - subgraph Frontend - A1[Basic Info Form] -->|Email + Password| A2[Subscription Selection] - A2 -->|Plan + Billing Cycle| A3[Payment Form] - A3 -->|Payment Method| A4[Process Payment] - end - - subgraph Backend Services - A4 -->|User Data + Payment| B1[Auth Service Register] - B1 -->|Create User| B2[User Created with Payment ID] - B2 -->|Call Tenant Service| B3[Tenant Service Create Subscription] - B3 -->|Create Subscription| B4[Subscription Created] - B4 -->|Return Subscription ID| B2 - B2 -->|Return Auth Tokens| A4 - end - - subgraph Onboarding - A4 -->|Success| C1[Onboarding Flow] - C1 -->|Tenant Creation| C2[RegisterTenantStep] - C2 -->|Tenant Data| C3[Tenant Service Create Tenant] - C3 -->|Link Subscription| C4[Link Subscription to Tenant] - C4 -->|Complete| C5[Onboarding Complete] - end -``` - -### Detailed Component Changes - -#### 1. Frontend Changes - -**RegisterForm.tsx Modifications:** -- **Phase 1**: Collect only email and password (basic info) -- **Phase 2**: Plan selection with billing cycle options -- **Phase 3**: Payment form with address and card details -- **Payment Processing**: Call new backend endpoint with complete registration data - -**New Payment Flow:** -```typescript -// Current: handleRegistrationSubmit calls authService.register directly -// New: handleRegistrationSubmit calls new registration endpoint -const handleRegistrationSubmit = async (paymentMethodId?: string) => { - try { - const registrationData = { - email: formData.email, - password: formData.password, - full_name: formData.full_name, - subscription_plan: selectedPlan, - billing_cycle: billingCycle, - payment_method_id: paymentMethodId, - coupon_code: isPilot ? couponCode : undefined, - // Address and billing info - address: billingAddress, - postal_code: billingPostalCode, - city: billingCity, - country: billingCountry - }; - - // Call new registration endpoint - const response = await authService.registerWithSubscription(registrationData); - - // Handle success and redirect to onboarding - onSuccess?.(); - } catch (err) { - // Handle errors - } -}; -``` - -#### 2. Auth Service Changes - -**New Registration Endpoint:** -```python -@router.post("/api/v1/auth/register-with-subscription") -async def register_with_subscription( - user_data: UserRegistrationWithSubscription, - auth_service: EnhancedAuthService = Depends(get_auth_service) -): - """Register user and create subscription in one call""" - - # Step 1: Create user - user = await auth_service.register_user(user_data) - - # Step 2: Create payment customer via tenant service - payment_result = await auth_service.create_payment_customer_via_tenant_service( - user_data, - user_data.payment_method_id - ) - - # Step 3: Create subscription via tenant service - subscription_result = await auth_service.create_subscription_via_tenant_service( - user.id, - user_data.subscription_plan, - user_data.payment_method_id, - user_data.billing_cycle, - user_data.coupon_code - ) - - # Step 4: Store subscription ID in user's onboarding progress - await auth_service.save_subscription_to_onboarding_progress( - user.id, - subscription_result.subscription_id, - user_data - ) - - return { - **user, - subscription_id: subscription_result.subscription_id - } -``` - -**Enhanced Auth Service Methods:** -```python -class EnhancedAuthService: - - async def create_subscription_via_tenant_service( - self, - user_id: str, - plan_id: str, - payment_method_id: str, - billing_cycle: str, - coupon_code: Optional[str] = None - ) -> Dict[str, Any]: - """Create subscription via tenant service during registration""" - - try: - from shared.clients.tenant_client import TenantServiceClient - from app.core.config import settings - - tenant_client = TenantServiceClient(settings) - - # Prepare user data for tenant service - user_data = await self.get_user_data_for_tenant_service(user_id) - - # Call tenant service to create subscription - result = await tenant_client.create_subscription_for_registration( - user_data=user_data, - plan_id=plan_id, - payment_method_id=payment_method_id, - billing_cycle=billing_cycle, - coupon_code=coupon_code - ) - - return result - - except Exception as e: - logger.error("Failed to create subscription via tenant service", - user_id=user_id, error=str(e)) - raise - - async def save_subscription_to_onboarding_progress( - self, - user_id: str, - subscription_id: str, - registration_data: Dict[str, Any] - ): - """Store subscription info in onboarding progress for later tenant linking""" - - try: - # Get or create onboarding progress - progress = await self.onboarding_repo.get_user_progress(user_id) - - if not progress: - progress = await self.onboarding_repo.create_user_progress(user_id) - - # Store subscription data in user_registered step - step_data = { - "subscription_id": subscription_id, - "subscription_plan": registration_data.subscription_plan, - "billing_cycle": registration_data.billing_cycle, - "coupon_code": registration_data.coupon_code, - "payment_method_id": registration_data.payment_method_id, - "payment_customer_id": registration_data.payment_customer_id, - "created_at": datetime.now(timezone.utc).isoformat(), - "status": "pending_tenant_linking" - } - - await self.onboarding_repo.upsert_user_step( - user_id=user_id, - step_name="user_registered", - completed=True, - step_data=step_data - ) - - logger.info("Subscription data saved to onboarding progress", - user_id=user_id, - subscription_id=subscription_id) - - except Exception as e: - logger.error("Failed to save subscription to onboarding progress", - user_id=user_id, error=str(e)) - raise -``` - -#### 3. Tenant Service Changes - -**New Subscription Creation Endpoint:** -```python -@router.post("/api/v1/subscriptions/create-for-registration") -async def create_subscription_for_registration( - user_data: Dict[str, Any], - plan_id: str = Query(...), - payment_method_id: str = Query(...), - billing_cycle: str = Query("monthly"), - coupon_code: Optional[str] = Query(None), - payment_service: PaymentService = Depends(get_payment_service), - db: AsyncSession = Depends(get_db) -): - """ - Create subscription during user registration (before tenant creation) - - This endpoint creates a subscription that is not yet linked to any tenant. - The subscription will be linked to a tenant during the onboarding flow. - """ - - try: - # Use orchestration service for complete workflow - orchestration_service = SubscriptionOrchestrationService(db) - - # Create subscription without tenant_id (tenant-independent subscription) - result = await orchestration_service.create_tenant_independent_subscription( - user_data, - plan_id, - payment_method_id, - billing_cycle, - coupon_code - ) - - logger.info("Tenant-independent subscription created for registration", - user_id=user_data.get('user_id'), - subscription_id=result["subscription_id"]) - - return { - "success": True, - "subscription_id": result["subscription_id"], - "customer_id": result["customer_id"], - "status": result["status"], - "plan": result["plan"], - "billing_cycle": result["billing_cycle"] - } - - except Exception as e: - logger.error("Failed to create tenant-independent subscription", - error=str(e), - user_id=user_data.get('user_id')) - raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail="Failed to create subscription" - ) -``` - -**Enhanced Subscription Orchestration Service:** -```python -class SubscriptionOrchestrationService: - - async def create_tenant_independent_subscription( - self, - user_data: Dict[str, Any], - plan_id: str, - payment_method_id: str, - billing_cycle: str = "monthly", - coupon_code: Optional[str] = None - ) -> Dict[str, Any]: - """ - Create a subscription that is not linked to any tenant yet - - This subscription will be linked to a tenant during onboarding - when the user creates their bakery/tenant. - """ - - try: - logger.info("Creating tenant-independent subscription", - user_id=user_data.get('user_id'), - plan_id=plan_id) - - # Step 1: Create customer in payment provider - customer = await self.payment_service.create_customer(user_data) - - # Step 2: Handle coupon logic - trial_period_days = 0 - coupon_discount = None - - if coupon_code: - 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) - - # Step 3: Create subscription in payment provider - stripe_subscription = 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_cycle - ) - - # Step 4: Create local subscription record WITHOUT tenant_id - subscription_record = await self.subscription_service.create_tenant_independent_subscription_record( - stripe_subscription.id, - customer.id, - plan_id, - stripe_subscription.status, - stripe_subscription.current_period_start, - stripe_subscription.current_period_end, - trial_period_days if trial_period_days > 0 else None, - billing_cycle, - user_data.get('user_id') - ) - - # Step 5: Store subscription in pending_tenant_linking state - await self.subscription_service.mark_subscription_as_pending_tenant_linking( - subscription_record.id, - user_data.get('user_id') - ) - - return { - "success": True, - "customer_id": customer.id, - "subscription_id": stripe_subscription.id, - "status": stripe_subscription.status, - "plan": plan_id, - "billing_cycle": billing_cycle, - "trial_period_days": trial_period_days, - "current_period_end": stripe_subscription.current_period_end.isoformat(), - "coupon_applied": bool(coupon_discount), - "user_id": user_data.get('user_id') - } - - except Exception as e: - logger.error("Failed to create tenant-independent subscription", - error=str(e), - user_id=user_data.get('user_id')) - raise -``` - -**New Subscription Service Methods:** -```python -class SubscriptionService: - - async def create_tenant_independent_subscription_record( - self, - subscription_id: str, - customer_id: str, - plan: str, - status: str, - current_period_start: datetime, - current_period_end: datetime, - trial_period_days: Optional[int] = None, - billing_cycle: str = "monthly", - user_id: Optional[str] = None - ) -> Subscription: - """Create subscription record without tenant_id""" - - try: - subscription_data = { - "subscription_id": subscription_id, - "customer_id": customer_id, - "plan": plan, - "status": status, - "current_period_start": current_period_start, - "current_period_end": current_period_end, - "trial_period_days": trial_period_days, - "billing_cycle": billing_cycle, - "user_id": user_id, - "tenant_id": None, # No tenant linked yet - "is_tenant_linked": False, - "created_at": datetime.now(timezone.utc), - "updated_at": datetime.now(timezone.utc) - } - - subscription = await self.subscription_repo.create(subscription_data) - - logger.info("Tenant-independent subscription record created", - subscription_id=subscription.id, - user_id=user_id) - - return subscription - - except Exception as e: - logger.error("Failed to create tenant-independent subscription record", - error=str(e)) - raise - - async def mark_subscription_as_pending_tenant_linking( - self, - subscription_id: str, - user_id: str - ): - """Mark subscription as pending tenant linking""" - - try: - await self.subscription_repo.update( - subscription_id, - { - "status": "pending_tenant_linking", - "tenant_linking_status": "pending", - "user_id": user_id - } - ) - - logger.info("Subscription marked as pending tenant linking", - subscription_id=subscription_id, - user_id=user_id) - - except Exception as e: - logger.error("Failed to mark subscription as pending tenant linking", - error=str(e), - subscription_id=subscription_id) - raise -``` - -#### 4. Onboarding Flow Changes - -**Enhanced RegisterTenantStep:** -```typescript -// When tenant is created, link the pending subscription -const handleSubmit = async () => { - if (!validateForm()) { - return; - } - - try { - let tenant; - if (tenantId) { - // Update existing tenant - const updateData: TenantUpdate = { ... }; - tenant = await updateTenant.mutateAsync({ tenantId, updateData }); - } else { - // Create new tenant and link subscription - const registrationData: BakeryRegistrationWithSubscription = { - ...formData, - // Include subscription linking data from onboarding progress - subscription_id: wizardContext.state.subscriptionId, - link_existing_subscription: true - }; - - tenant = await registerBakery.mutateAsync(registrationData); - } - - // Continue with onboarding - onComplete({ tenant, tenantId: tenant.id }); - - } catch (error) { - console.error('Error registering bakery:', error); - setErrors({ submit: t('onboarding:steps.tenant_registration.errors.register') }); - } -}; -``` - -**Enhanced Tenant Creation Endpoint:** -```python -@router.post(route_builder.build_base_route("register", include_tenant_prefix=False)) -async def register_bakery( - bakery_data: BakeryRegistrationWithSubscription, - current_user: Dict[str, Any] = Depends(get_current_user_dep), - tenant_service: EnhancedTenantService = Depends(get_enhanced_tenant_service), - db: AsyncSession = Depends(get_db) -): - """Register a new bakery/tenant with subscription linking""" - - try: - // Create tenant first - result = await tenant_service.create_bakery(bakery_data, current_user["user_id"]) - tenant_id = result["tenant_id"] - - // Check if we need to link an existing subscription - if bakery_data.link_existing_subscription and bakery_data.subscription_id: - // Link the pending subscription to this tenant - subscription_result = await tenant_service.link_subscription_to_tenant( - tenant_id, - bakery_data.subscription_id, - current_user["user_id"] - ) - - logger.info("Subscription linked to tenant during registration", - tenant_id=tenant_id, - subscription_id=bakery_data.subscription_id) - else: - // Fallback to current behavior for backward compatibility - // Create new subscription if needed - pass - - return result - - except Exception as e: - logger.error("Failed to register bakery with subscription linking", - error=str(e), - user_id=current_user["user_id"]) - raise -``` - -**New Tenant Service Method for Subscription Linking:** -```python -class EnhancedTenantService: - - async def link_subscription_to_tenant( - self, - tenant_id: str, - subscription_id: str, - user_id: str - ) -> Dict[str, Any]: - """Link a pending subscription to a tenant""" - - try: - async with self.database_manager.get_session() as db_session: - async with UnitOfWork(db_session) as uow: - # Register repositories - subscription_repo = uow.register_repository( - "subscriptions", SubscriptionRepository, Subscription - ) - tenant_repo = uow.register_repository( - "tenants", TenantRepository, Tenant - ) - - # Get the subscription - subscription = await subscription_repo.get_by_id(subscription_id) - - if not subscription: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail="Subscription not found" - ) - - # Verify subscription is in pending_tenant_linking state - if subscription.tenant_linking_status != "pending": - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail="Subscription is not in pending tenant linking state" - ) - - # Verify subscription belongs to this user - if subscription.user_id != user_id: - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, - detail="Subscription does not belong to this user" - ) - - # Update subscription with tenant_id - update_data = { - "tenant_id": tenant_id, - "is_tenant_linked": True, - "tenant_linking_status": "completed", - "linked_at": datetime.now(timezone.utc) - } - - await subscription_repo.update(subscription_id, update_data) - - # Update tenant with subscription information - tenant_update = { - "stripe_customer_id": subscription.customer_id, - "subscription_status": subscription.status, - "subscription_plan": subscription.plan, - "subscription_tier": subscription.plan, - "billing_cycle": subscription.billing_cycle, - "trial_period_days": subscription.trial_period_days - } - - await tenant_repo.update_tenant(tenant_id, tenant_update) - - # Commit transaction - await uow.commit() - - logger.info("Subscription successfully linked to tenant", - tenant_id=tenant_id, - subscription_id=subscription_id, - user_id=user_id) - - return { - "success": True, - "tenant_id": tenant_id, - "subscription_id": subscription_id, - "status": "linked" - } - - except Exception as e: - logger.error("Failed to link subscription to tenant", - error=str(e), - tenant_id=tenant_id, - subscription_id=subscription_id, - user_id=user_id) - raise -``` - -## Database Schema Changes - -### New Subscription Table Structure - -```sql --- Add new columns to subscriptions table -ALTER TABLE subscriptions ADD COLUMN IF NOT EXISTS user_id UUID; -ALTER TABLE subscriptions ADD COLUMN IF NOT EXISTS is_tenant_linked BOOLEAN DEFAULT FALSE; -ALTER TABLE subscriptions ADD COLUMN IF NOT EXISTS tenant_linking_status VARCHAR(50); -ALTER TABLE subscriptions ADD COLUMN IF NOT EXISTS linked_at TIMESTAMP; - --- Add index for user-based subscription queries -CREATE INDEX IF NOT EXISTS idx_subscriptions_user_id ON subscriptions(user_id); -CREATE INDEX IF NOT EXISTS idx_subscriptions_linking_status ON subscriptions(tenant_linking_status); - --- Add constraint to ensure tenant_id is NULL when not linked -ALTER TABLE subscriptions ADD CONSTRAINT chk_tenant_linking - CHECK ((is_tenant_linked = FALSE AND tenant_id IS NULL) OR - (is_tenant_linked = TRUE AND tenant_id IS NOT NULL)); -``` - -### Onboarding Progress Data Structure - -```json -{ - "user_id": "user-uuid", - "current_step": "user_registered", - "steps": [ - { - "step_name": "user_registered", - "completed": true, - "completed_at": "2025-10-15T10:30:00Z", - "data": { - "subscription_id": "sub-uuid", - "subscription_plan": "professional", - "billing_cycle": "yearly", - "coupon_code": "PILOT2025", - "payment_method_id": "pm-123", - "payment_customer_id": "cus-456", - "status": "pending_tenant_linking", - "created_at": "2025-10-15T10:30:00Z" - } - } - ] -} -``` - -## Error Handling & Recovery - -### Error Scenarios and Recovery Strategies - -1. **Payment Processing Failure** - - **Scenario**: Payment fails during registration - - **Recovery**: Rollback user creation, show error to user, allow retry - - **Implementation**: Transaction management in auth service - -2. **Subscription Creation Failure** - - **Scenario**: Subscription creation fails after user creation - - **Recovery**: User created but marked as "registration_incomplete", allow retry in onboarding - - **Implementation**: Store registration state, provide recovery endpoint - -3. **Tenant Linking Failure** - - **Scenario**: Tenant creation succeeds but subscription linking fails - - **Recovery**: Tenant created with default trial subscription, manual linking available - - **Implementation**: Fallback to current behavior, admin notification - -4. **Orphaned Subscriptions** - - **Scenario**: User registers but never completes onboarding - - **Recovery**: Cleanup task to cancel subscriptions after 30 days - - **Implementation**: Background job to monitor pending subscriptions - -### Monitoring and Alerts - -```python -# Subscription linking monitoring -class SubscriptionMonitoringService: - - async def monitor_pending_subscriptions(self): - """Monitor subscriptions pending tenant linking""" - - pending_subscriptions = await self.subscription_repo.get_pending_tenant_linking() - - for subscription in pending_subscriptions: - created_days_ago = (datetime.now(timezone.utc) - subscription.created_at).days - - if created_days_ago > 30: - # Cancel subscription and notify user - await self.cancel_orphaned_subscription(subscription.id) - await self.notify_user_about_cancellation(subscription.user_id) - elif created_days_ago > 7: - # Send reminder to complete onboarding - await self.send_onboarding_reminder(subscription.user_id) -``` - -## Migration Strategy - -### Phase 1: Backend Implementation -1. **Database Migration**: Add new columns to subscriptions table -2. **Auth Service Updates**: Implement new registration endpoint -3. **Tenant Service Updates**: Implement tenant-independent subscription creation -4. **Shared Clients**: Update inter-service communication - -### Phase 2: Frontend Implementation -1. **Registration Form**: Update to collect billing address -2. **Payment Flow**: Integrate with new backend endpoints -3. **Onboarding Flow**: Add subscription linking logic - -### Phase 3: Testing and Validation -1. **Unit Tests**: Verify individual component behavior -2. **Integration Tests**: Test service-to-service communication -3. **End-to-End Tests**: Validate complete user journey -4. **Load Testing**: Ensure performance under load - -### Phase 4: Deployment and Rollout -1. **Feature Flags**: Enable gradual rollout -2. **A/B Testing**: Compare with existing flow -3. **Monitoring**: Track key metrics and errors -4. **Rollback Plan**: Prepare for quick rollback if needed - -## Benefits of the New Architecture - -### 1. Improved User Experience -- **Clear Separation of Concerns**: Users understand each step of the process -- **Progressive Commitment**: Users can complete registration without immediate tenant creation -- **Flexible Onboarding**: Users can explore the platform before committing to a specific bakery - -### 2. Better Error Handling -- **Isolated Failure Points**: Failures in one step don't cascade to others -- **Recovery Paths**: Clear recovery mechanisms for each failure scenario -- **Graceful Degradation**: System remains functional even with partial failures - -### 3. Enhanced Business Flexibility -- **Multi-Tenant Support**: Users can create multiple tenants with the same subscription -- **Subscription Portability**: Subscriptions can be moved between tenants -- **Trial Management**: Better control over trial periods and conversions - -### 4. Improved Security -- **Data Isolation**: Sensitive payment data handled separately from user data -- **Audit Trails**: Clear tracking of subscription lifecycle -- **Compliance**: Better support for GDPR and payment industry standards - -### 5. Scalability -- **Microservice Alignment**: Better separation between auth and tenant services -- **Independent Scaling**: Services can be scaled independently -- **Future Extensibility**: Easier to add new features and integrations - -## Implementation Timeline - -| Phase | Duration | Key Activities | -|-------|----------|----------------| -| 1. Analysis & Design | 2 weeks | Architecture review, technical design, stakeholder approval | -| 2. Backend Implementation | 4 weeks | Database changes, service updates, API development | -| 3. Frontend Implementation | 3 weeks | Form updates, payment integration, onboarding changes | -| 4. Testing & QA | 3 weeks | Unit tests, integration tests, E2E tests, performance testing | -| 5. Deployment & Rollout | 2 weeks | Staging deployment, production rollout, monitoring setup | -| 6. Post-Launch | Ongoing | Bug fixes, performance optimization, feature enhancements | - -## Risks and Mitigation - -### Technical Risks -1. **Data Consistency**: Risk of inconsistent state between services - - *Mitigation*: Strong transaction management, idempotent operations, reconciliation jobs - -2. **Performance Impact**: Additional service calls may impact performance - - *Mitigation*: Caching, async processing, performance optimization - -3. **Complexity Increase**: More moving parts increase system complexity - - *Mitigation*: Clear documentation, comprehensive monitoring, gradual rollout - -### Business Risks -1. **User Confusion**: Multi-step process may confuse some users - - *Mitigation*: Clear UI guidance, progress indicators, help documentation - -2. **Conversion Impact**: Additional steps may reduce conversion rates - - *Mitigation*: A/B testing, user feedback, iterative improvements - -3. **Support Burden**: New flow may require additional support - - *Mitigation*: Comprehensive documentation, self-service recovery, support training - -## Success Metrics - -### Key Performance Indicators -1. **Registration Completion Rate**: Percentage of users completing registration -2. **Onboarding Completion Rate**: Percentage of users completing onboarding -3. **Error Rates**: Frequency of errors in each step -4. **Conversion Rates**: Percentage of visitors becoming paying customers -5. **User Satisfaction**: Feedback and ratings from users - -### Monitoring Dashboard -``` -Registration Funnel: -- Step 1 (Basic Info): 100% -- Step 2 (Plan Selection): 85% -- Step 3 (Payment): 75% -- Onboarding Completion: 60% - -Error Metrics: -- Registration Errors: < 1% -- Payment Errors: < 2% -- Subscription Linking Errors: < 0.5% - -Performance Metrics: -- Registration Time: < 5s -- Payment Processing Time: < 3s -- Tenant Creation Time: < 2s -``` - -## Conclusion - -This rearchitecture proposal addresses the current limitations by implementing a clear separation between user registration, payment processing, and tenant creation. The new multi-phase approach provides better user experience, improved error handling, and enhanced business flexibility while maintaining backward compatibility and providing clear migration paths. - -The proposed solution aligns with modern microservice architectures and provides a solid foundation for future growth and feature enhancements. \ No newline at end of file diff --git a/frontend/src/api/types/auth.ts b/frontend/src/api/types/auth.ts index 21966070..6e1136a4 100644 --- a/frontend/src/api/types/auth.ts +++ b/frontend/src/api/types/auth.ts @@ -31,6 +31,9 @@ export interface UserRegistration { billing_cycle?: 'monthly' | 'yearly' | null; // Default: "monthly" - Billing cycle preference payment_method_id?: string | null; // Stripe payment method ID coupon_code?: string | null; // Promotional coupon code for discounts/trial extensions + // Payment setup data (passed to complete-registration after 3DS) + customer_id?: string | null; // Stripe customer ID from payment setup + trial_period_days?: number | null; // Trial period from coupon // GDPR Consent fields terms_accepted?: boolean; // Default: true - Accept terms of service privacy_accepted?: boolean; // Default: true - Accept privacy policy @@ -68,6 +71,7 @@ export interface RegistrationStartResponse { plan_id?: string | null; // Plan ID payment_method_id?: string | null; // Payment method ID billing_cycle?: string | null; // Billing cycle + trial_period_days?: number | null; // Trial period from coupon (e.g., 90 for PILOT2025) email?: string | null; // User email state_id?: string | null; // Registration state ID for tracking message?: string | null; // Message explaining what needs to be done diff --git a/frontend/src/components/domain/auth/PaymentStep.tsx b/frontend/src/components/domain/auth/PaymentStep.tsx index bffa8f87..9cbec17b 100644 --- a/frontend/src/components/domain/auth/PaymentStep.tsx +++ b/frontend/src/components/domain/auth/PaymentStep.tsx @@ -264,7 +264,7 @@ export const PaymentStep: React.FC = ({ // Store payment method ID for use after 3DS completion setPendingPaymentMethodId(paymentMethod.id); - // Update paymentSetup state with customer_id for redirect recovery + // Update paymentSetup state with customer_id and trial_period_days for redirect recovery updateRegistrationState({ paymentSetup: { success: false, @@ -272,6 +272,7 @@ export const PaymentStep: React.FC = ({ subscriptionId: '', paymentMethodId: paymentMethod.id, planId: registrationState.subscription.planId, + trialPeriodDays: paymentSetupResult.trial_period_days ?? (registrationState.subscription.useTrial ? 90 : 0), }, }); @@ -353,6 +354,7 @@ export const PaymentStep: React.FC = ({ : setupIntent.payment_method?.id || pendingPaymentMethodId; // Complete registration with verified SetupIntent using React Query mutation + // Send coupon_code to backend for trial period calculation const verificationResult = await completeRegistrationMutation.mutateAsync({ setup_intent_id: setupIntentId || '', user_data: { @@ -363,6 +365,9 @@ export const PaymentStep: React.FC = ({ billing_cycle: registrationState.subscription.billingInterval, payment_method_id: confirmedPaymentMethodId || pendingPaymentMethodId, coupon_code: registrationState.subscription.couponCode, + // Pass customer_id for reference + customer_id: registrationState.paymentSetup?.customerId || '', + // Remove trial_period_days - backend will calculate from coupon_code }, }); diff --git a/frontend/src/components/domain/auth/hooks/useRegistrationState.ts b/frontend/src/components/domain/auth/hooks/useRegistrationState.ts index 4b7957c0..2b93e8d3 100644 --- a/frontend/src/components/domain/auth/hooks/useRegistrationState.ts +++ b/frontend/src/components/domain/auth/hooks/useRegistrationState.ts @@ -40,6 +40,7 @@ export type PaymentSetupData = { paymentMethodId?: string; planId?: string; threedsCompleted?: boolean; + trialPeriodDays?: number; // Trial period from coupon (e.g., 90 for PILOT2025) }; export type RegistrationState = { diff --git a/services/auth/app/services/auth_service.py b/services/auth/app/services/auth_service.py index 7ac92392..cb032b3d 100644 --- a/services/auth/app/services/auth_service.py +++ b/services/auth/app/services/auth_service.py @@ -129,13 +129,15 @@ class AuthService: payment_setup_result: Optional[Dict[str, Any]] = None ) -> Dict[str, Any]: """ - Complete registration after successful payment verification - This is called AFTER frontend confirms SetupIntent and handles 3DS + Complete registration after successful payment verification. + + NEW ARCHITECTURE: This calls tenant service to create subscription + AFTER SetupIntent verification. No subscription exists until this point. Args: - setup_intent_id: Verified SetupIntent ID (may be None if no 3DS was required) + setup_intent_id: Verified SetupIntent ID user_data: User registration data - payment_setup_result: Optional payment setup result with subscription info + payment_setup_result: Optional payment setup result with customer_id etc. Returns: Complete registration result @@ -144,26 +146,37 @@ class AuthService: RegistrationError: If registration completion fails """ try: - logger.info(f"Completing registration after payment verification, email={user_data.email}, setup_intent_id={setup_intent_id}") + logger.info(f"Completing registration after verification, email={user_data.email}, setup_intent_id={setup_intent_id}") - # If we already have subscription info from payment_setup_result, use it - # This happens when no 3DS was required and subscription was created immediately - if payment_setup_result and payment_setup_result.get('subscription_id'): - subscription_result = payment_setup_result - elif setup_intent_id: - # Step 1: Verify SetupIntent and create subscription via tenant service - subscription_result = await self.tenant_client.verify_and_complete_registration( - setup_intent_id, - { - "email": user_data.email, - "full_name": user_data.full_name, - "plan_id": user_data.subscription_plan or "professional", - "billing_cycle": user_data.billing_cycle or "monthly", - "coupon_code": user_data.coupon_code - } - ) - else: - raise RegistrationError("No setup_intent_id or subscription_id available for registration completion") + if not setup_intent_id: + raise RegistrationError("SetupIntent ID is required for registration completion") + + # Get customer_id and other data from payment_setup_result + customer_id = "" + payment_method_id = "" + trial_period_days = 0 + + if payment_setup_result: + customer_id = payment_setup_result.get('customer_id') or payment_setup_result.get('payment_customer_id', '') + payment_method_id = payment_setup_result.get('payment_method_id', '') + trial_period_days = payment_setup_result.get('trial_period_days', 0) + + # Call tenant service to verify SetupIntent and CREATE subscription + subscription_result = await self.tenant_client.verify_and_complete_registration( + setup_intent_id, + { + "email": user_data.email, + "full_name": user_data.full_name, + "plan_id": user_data.subscription_plan or "professional", + "subscription_plan": user_data.subscription_plan or "professional", + "billing_cycle": user_data.billing_cycle or "monthly", + "billing_interval": user_data.billing_cycle or "monthly", + "coupon_code": user_data.coupon_code, + "customer_id": customer_id, + "payment_method_id": payment_method_id or user_data.payment_method_id, + "trial_period_days": trial_period_days + } + ) # Use a single database session for both user creation and onboarding progress # to ensure proper transaction handling and avoid foreign key constraint violations @@ -256,37 +269,35 @@ class AuthService: # Check if SetupIntent requires action (3DS) if payment_setup_result.get('requires_action', False): - logger.info(f"Registration requires SetupIntent confirmation (3DS), email={user_data.email}, setup_intent_id={payment_setup_result.get('setup_intent_id')}, subscription_id={payment_setup_result.get('subscription_id')}") + logger.info(f"Registration requires SetupIntent confirmation (3DS), email={user_data.email}, setup_intent_id={payment_setup_result.get('setup_intent_id')}") # Return SetupIntent for frontend to handle 3DS - # Note: subscription_id is included because for trial subscriptions, - # the subscription is already created in 'trialing' status + # Note: NO subscription exists yet - subscription is created after verification return { 'requires_action': True, 'action_type': 'setup_intent_confirmation', 'client_secret': payment_setup_result.get('client_secret'), 'setup_intent_id': payment_setup_result.get('setup_intent_id'), - 'subscription_id': payment_setup_result.get('subscription_id'), 'customer_id': payment_setup_result.get('customer_id'), 'payment_customer_id': payment_setup_result.get('payment_customer_id'), 'plan_id': payment_setup_result.get('plan_id'), 'payment_method_id': payment_setup_result.get('payment_method_id'), 'billing_cycle': payment_setup_result.get('billing_cycle'), - 'coupon_info': payment_setup_result.get('coupon_info'), - 'trial_info': payment_setup_result.get('trial_info'), + 'trial_period_days': payment_setup_result.get('trial_period_days', 0), + 'coupon_code': payment_setup_result.get('coupon_code'), 'email': payment_setup_result.get('email'), - 'message': 'Payment verification required. Frontend must confirm SetupIntent to handle 3DS.' + 'message': 'Payment verification required. Frontend must confirm SetupIntent.' } else: - logger.info(f"Registration payment setup completed without 3DS, email={user_data.email}, customer_id={payment_setup_result.get('customer_id')}") + # No 3DS required - SetupIntent already succeeded + logger.info(f"Registration SetupIntent succeeded without 3DS, email={user_data.email}, setup_intent_id={payment_setup_result.get('setup_intent_id')}") - # No 3DS required - proceed with user creation and subscription - # setup_intent_id may be None if no 3DS was required - use subscription_id instead + # Complete registration - create subscription now setup_intent_id = payment_setup_result.get('setup_intent_id') registration_result = await self.complete_registration_after_payment_verification( setup_intent_id, user_data, - payment_setup_result # Pass full result for additional context + payment_setup_result ) return { @@ -295,8 +306,8 @@ class AuthService: 'subscription_id': registration_result.get('subscription_id'), 'payment_customer_id': registration_result.get('payment_customer_id'), 'status': registration_result.get('status'), - 'coupon_info': registration_result.get('coupon_info'), - 'trial_info': registration_result.get('trial_info'), + 'access_token': registration_result.get('access_token'), + 'refresh_token': registration_result.get('refresh_token'), 'message': 'Registration completed successfully' } diff --git a/services/tenant/app/api/subscription.py b/services/tenant/app/api/subscription.py index ef014b06..0a644ce5 100644 --- a/services/tenant/app/api/subscription.py +++ b/services/tenant/app/api/subscription.py @@ -1,6 +1,13 @@ """ Tenant Service API Endpoints for Subscription and Registration -Updated with new atomic registration flow support + +NEW ARCHITECTURE (SetupIntent-first): +1. /registration-payment-setup - Creates customer + SetupIntent only (NO subscription) +2. Frontend confirms SetupIntent (handles 3DS if needed) +3. /verify-and-complete-registration - Creates subscription AFTER verification + +This eliminates duplicate subscriptions by only creating the subscription +after payment verification is complete. """ import logging @@ -8,6 +15,7 @@ from typing import Dict, Any from fastapi import APIRouter, Depends, HTTPException, status, Request, Query from sqlalchemy.ext.asyncio import AsyncSession from app.services.subscription_orchestration_service import SubscriptionOrchestrationService +from app.services.coupon_service import CouponService from app.core.database import get_db from app.services.registration_state_service import ( registration_state_service, @@ -18,17 +26,13 @@ from shared.exceptions.payment_exceptions import ( PaymentServiceError, SetupIntentError, SubscriptionCreationFailed, - ThreeDSAuthenticationRequired ) from shared.exceptions.registration_exceptions import ( RegistrationStateError, - InvalidStateTransitionError ) -# Configure logging logger = logging.getLogger(__name__) -# Create router router = APIRouter(prefix="/api/v1/tenants", tags=["tenant"]) @@ -46,7 +50,7 @@ async def get_registration_state_service() -> RegistrationStateService: @router.post("/registration-payment-setup", response_model=Dict[str, Any], - summary="Initiate registration payment setup") + summary="Start registration payment setup") async def create_registration_payment_setup( user_data: Dict[str, Any], request: Request, @@ -54,69 +58,46 @@ async def create_registration_payment_setup( state_service: RegistrationStateService = Depends(get_registration_state_service) ) -> Dict[str, Any]: """ - Initiate registration payment setup with SetupIntent-first approach - - This is the FIRST step in secure registration flow: - 1. Creates payment customer - 2. Attaches payment method - 3. Creates SetupIntent for verification - 4. Returns SetupIntent to frontend for 3DS handling - + Start registration payment setup (SetupIntent-first architecture). + + NEW ARCHITECTURE: Only creates customer + SetupIntent here. + NO subscription is created - subscription is created in verify-and-complete-registration. + + Flow: + 1. Create Stripe customer + 2. Create SetupIntent for payment verification + 3. Return SetupIntent to frontend for 3DS handling + 4. Frontend confirms SetupIntent + 5. (Next endpoint) Creates subscription after verification + Args: user_data: User registration data with payment info - + Returns: - Payment setup result (may require 3DS) - - Raises: - HTTPException: 400 for validation errors, 500 for server errors + SetupIntent data for frontend confirmation """ + state_id = None try: - print(f"DEBUG_PRINT: Registration payment setup request received for {user_data.get('email')}") - logger.critical( - "Registration payment setup request received (CRITICAL)", - extra={ - "email": user_data.get('email'), - "plan_id": user_data.get('plan_id') - } - ) - + logger.info("Registration payment setup started", + extra={"email": user_data.get('email'), "plan_id": user_data.get('plan_id')}) + # Validate required fields if not user_data.get('email'): - logger.error("Registration payment setup failed: Email missing") - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail="Email is required" - ) - + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Email is required") + if not user_data.get('payment_method_id'): - logger.error("Registration payment setup failed: Payment method ID missing", extra={"email": user_data.get('email')}) - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail="Payment method ID is required" - ) + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Payment method ID is required") if not user_data.get('plan_id'): - logger.error("Registration payment setup failed: Plan ID missing", extra={"email": user_data.get('email')}) - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail="Plan ID is required" - ) - + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Plan ID is required") + # Create registration state - print(f"DEBUG_PRINT: Creating registration state for {user_data['email']}") - logger.critical("Creating registration state", extra={"email": user_data['email']}) - - state_id = await state_service.create_registration_state( email=user_data['email'], user_data=user_data ) - logger.critical("Registration state created", extra={"state_id": state_id, "email": user_data['email']}) - # Initiate payment setup - print(f"DEBUG_PRINT: Calling orchestration service for {user_data['email']}") - logger.critical("Calling orchestration service for payment setup", extra={"state_id": state_id, "email": user_data['email']}) + # Create customer + SetupIntent (NO subscription yet!) result = await orchestration_service.create_registration_payment_setup( user_data=user_data, plan_id=user_data.get('plan_id', 'professional'), @@ -124,108 +105,55 @@ async def create_registration_payment_setup( billing_interval=user_data.get('billing_cycle', 'monthly'), coupon_code=user_data.get('coupon_code') ) - logger.critical("Payment orchestration completed", extra={"state_id": state_id, "email": user_data['email'], "requires_action": result.get('requires_action')}) - - # Update state with payment setup results - # Note: setup_intent_id may not be present if no 3DS was required + + # Update state with setup results await state_service.update_state_context(state_id, { 'setup_intent_id': result.get('setup_intent_id'), - 'subscription_id': result.get('subscription_id'), 'customer_id': result.get('customer_id'), - 'payment_customer_id': result.get('payment_customer_id'), - 'payment_method_id': result.get('payment_method_id') + 'payment_method_id': result.get('payment_method_id'), + 'plan_id': result.get('plan_id'), + 'billing_interval': result.get('billing_interval'), + 'trial_period_days': result.get('trial_period_days'), + 'coupon_code': result.get('coupon_code') }) - - # Transition to payment verification pending state - await state_service.transition_state( - state_id, - RegistrationState.PAYMENT_VERIFICATION_PENDING - ) - - logger.critical( - "Registration payment setup flow successful", - extra={ - "email": user_data.get('email'), - "state_id": state_id - } - ) + + await state_service.transition_state(state_id, RegistrationState.PAYMENT_VERIFICATION_PENDING) + + logger.info("Registration payment setup completed", + extra={ + "email": user_data.get('email'), + "setup_intent_id": result.get('setup_intent_id'), + "requires_action": result.get('requires_action') + }) return { "success": True, - "requires_action": result.get('requires_action', False), - "action_type": result.get('action_type'), + "requires_action": result.get('requires_action', True), + "action_type": result.get('action_type', 'use_stripe_sdk'), "client_secret": result.get('client_secret'), "setup_intent_id": result.get('setup_intent_id'), "customer_id": result.get('customer_id'), - "payment_customer_id": result.get('payment_customer_id'), + "payment_customer_id": result.get('customer_id'), "plan_id": result.get('plan_id'), "payment_method_id": result.get('payment_method_id'), - "subscription_id": result.get('subscription_id'), - "billing_cycle": result.get('billing_cycle'), + "trial_period_days": result.get('trial_period_days', 0), + "billing_cycle": result.get('billing_interval'), "email": result.get('email'), "state_id": state_id, - "message": result.get('message') or "Payment setup completed successfully." - } - - except ThreeDSAuthenticationRequired as e: - # 3DS authentication required - return SetupIntent data for frontend - logger.info(f"3DS authentication required for registration: email={user_data.get('email')}, setup_intent_id={e.setup_intent_id}", extra={"email": user_data.get('email'), "setup_intent_id": e.setup_intent_id}) - - # Update state with payment setup results - await state_service.update_state_context(state_id, { - 'setup_intent_id': e.setup_intent_id, - 'customer_id': e.extra_data.get('customer_id'), - 'payment_customer_id': e.extra_data.get('customer_id'), - 'payment_method_id': e.extra_data.get('payment_method_id') - }) - - # Transition to payment verification pending state - await state_service.transition_state( - state_id, - RegistrationState.PAYMENT_VERIFICATION_PENDING - ) - - return { - "success": True, - "requires_action": True, - "action_type": e.action_type, - "client_secret": e.client_secret, - "setup_intent_id": e.setup_intent_id, - "subscription_id": e.extra_data.get('subscription_id'), - "customer_id": e.extra_data.get('customer_id'), - "payment_customer_id": e.extra_data.get('customer_id'), - "plan_id": e.extra_data.get('plan_id'), - "payment_method_id": e.extra_data.get('payment_method_id'), - "billing_cycle": e.extra_data.get('billing_interval'), - "email": e.extra_data.get('email'), - "state_id": state_id, - "message": e.extra_data.get('message') or "Payment verification required. Frontend must confirm SetupIntent to handle 3DS." + "message": result.get('message', 'Payment verification required') } except PaymentServiceError as e: - logger.error(f"Payment service error in registration setup: {str(e)}, email: {user_data.get('email')}", - extra={"email": user_data.get('email')}, - exc_info=True) - raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail=f"Payment setup failed: {str(e)}" - ) from e + logger.error(f"Payment setup failed: {str(e)}", extra={"email": user_data.get('email')}, exc_info=True) + raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"Payment setup failed: {str(e)}") from e except RegistrationStateError as e: - logger.error(f"Registration state error in payment setup: {str(e)}, email: {user_data.get('email')}", - extra={"email": user_data.get('email')}, - exc_info=True) - raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail=f"Registration state error: {str(e)}" - ) from e + logger.error(f"Registration state error: {str(e)}", extra={"email": user_data.get('email')}, exc_info=True) + raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"Registration state error: {str(e)}") from e + except HTTPException: + raise except Exception as e: - logger.error(f"Unexpected error in registration payment setup: {str(e)}, email: {user_data.get('email')}", - extra={"email": user_data.get('email')}, - exc_info=True) - raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail=f"Registration payment setup failed: {str(e)}" - ) from e + logger.error(f"Unexpected error: {str(e)}", extra={"email": user_data.get('email')}, exc_info=True) + raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"Registration failed: {str(e)}") from e @router.post("/verify-and-complete-registration", @@ -234,258 +162,153 @@ async def create_registration_payment_setup( async def verify_and_complete_registration( verification_data: Dict[str, Any], request: Request, + db: AsyncSession = Depends(get_db), orchestration_service: SubscriptionOrchestrationService = Depends(get_subscription_orchestration_service), state_service: RegistrationStateService = Depends(get_registration_state_service) ) -> Dict[str, Any]: """ - Complete registration after frontend confirms SetupIntent (3DS handled) - - This is the SECOND step in registration architecture: - 1. Verifies SetupIntent status - 2. Creates subscription with verified payment method - 3. Updates registration state - + Complete registration after frontend confirms SetupIntent. + + NEW ARCHITECTURE: Creates subscription HERE (not in payment-setup). + This is the ONLY place subscriptions are created during registration. + + Flow: + 1. Verify SetupIntent status is 'succeeded' + 2. Create subscription with verified payment method + 3. Update registration state + Args: - verification_data: SetupIntent verification data - + verification_data: SetupIntent verification data with user_data + Returns: - Complete registration result with subscription - - Raises: - HTTPException: 400 for validation errors, 500 for server errors + Subscription creation result """ + setup_intent_id = None + user_data = {} + state_id = None + try: # Validate required fields if not verification_data.get('setup_intent_id'): - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail="SetupIntent ID is required" - ) - + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="SetupIntent ID is required") + if not verification_data.get('user_data'): - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail="User data is required" - ) - + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="User data is required") + setup_intent_id = verification_data['setup_intent_id'] user_data = verification_data['user_data'] state_id = verification_data.get('state_id') - - logger.info( - "Completing registration after SetupIntent verification", - extra={ - "email": user_data.get('email'), - "setup_intent_id": setup_intent_id - } - ) - - # Get registration state if provided - if state_id: - try: - registration_state = await state_service.get_registration_state(state_id) - logger.info( - "Retrieved registration state", - extra={ - "state_id": state_id, - "current_state": registration_state['current_state'] - } - ) - except RegistrationStateError: - logger.warning("Registration state not found, proceeding without state tracking", - extra={"state_id": state_id}) - state_id = None - - # First verify the setup intent to get the actual customer_id and payment_method_id - verification_result = await orchestration_service.verify_setup_intent_for_registration( - setup_intent_id - ) - if not verification_result.get('verified', False): - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail="SetupIntent verification failed" - ) + logger.info("Completing registration after verification", + extra={"email": user_data.get('email'), "setup_intent_id": setup_intent_id}) - # Extract actual values from verification result - actual_customer_id = verification_result.get('customer_id', user_data.get('customer_id', '')) - actual_payment_method_id = verification_result.get('payment_method_id', user_data.get('payment_method_id', '')) - - # Get trial period from coupon if available - trial_period_days = user_data.get('trial_period_days', 0) + # Calculate trial period from coupon if provided in the completion call + trial_period_days = 0 coupon_code = user_data.get('coupon_code') - # If we have a coupon code but no trial period, redeem the coupon to get trial days - if coupon_code and trial_period_days == 0: - try: - from app.services.coupon_service import CouponService - coupon_service = CouponService(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: - trial_period_days = discount_applied.get("total_trial_days", 0) - logger.info("Retrieved trial period from coupon for verification", - extra={"coupon_code": coupon_code, "trial_period_days": trial_period_days}) - except Exception as e: - logger.error("Failed to redeem coupon during verification, using default trial period", - extra={"coupon_code": coupon_code, "error": str(e)}, - exc_info=True) - # Fall back to 0 if coupon redemption fails - trial_period_days = 0 + if coupon_code: + logger.info("Validating coupon in completion call", + extra={"coupon_code": coupon_code, "email": user_data.get('email')}) + + # Create coupon service to validate coupon + coupon_service = CouponService(db) + 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: + trial_period_days = discount_applied.get("total_trial_days", 0) + logger.info("Coupon validated in completion call", + extra={"coupon_code": coupon_code, "trial_period_days": trial_period_days}) + else: + logger.warning("Failed to validate coupon in completion call", + extra={"coupon_code": coupon_code, "error": error}) + elif 'trial_period_days' in user_data: + # Fallback: use trial_period_days if explicitly provided + trial_period_days = int(user_data.get('trial_period_days', 0)) + logger.info("Using explicitly provided trial period", + extra={"trial_period_days": trial_period_days}) + + # Create subscription AFTER verification (the core fix!) + result = await orchestration_service.complete_registration_subscription( + setup_intent_id=setup_intent_id, + customer_id=user_data.get('customer_id', ''), + plan_id=user_data.get('plan_id') or user_data.get('subscription_plan', 'professional'), + payment_method_id=user_data.get('payment_method_id', ''), + billing_interval=user_data.get('billing_cycle') or user_data.get('billing_interval', 'monthly'), + trial_period_days=trial_period_days, + user_id=user_data.get('user_id') + ) - # Check if a subscription already exists for this customer - existing_subscriptions = await orchestration_service.get_subscriptions_by_customer_id(actual_customer_id) - - if existing_subscriptions: - # If we already have a trial subscription, update it instead of creating a new one - existing_subscription = existing_subscriptions[0] # Get the first subscription - - logger.info("Found existing subscription, updating with verified payment method", - extra={ - "customer_id": actual_customer_id, - "subscription_id": existing_subscription.provider_subscription_id, - "existing_status": existing_subscription.status - }) - - # Update the existing subscription with the verified payment method - result = await orchestration_service.update_subscription_with_verified_payment( - existing_subscription.provider_subscription_id, - actual_customer_id, - actual_payment_method_id, - trial_period_days - ) - else: - # No existing subscription, create a new one - result = await orchestration_service.complete_subscription_after_setup_intent( - setup_intent_id, - actual_customer_id, - user_data.get('plan_id', 'starter'), - actual_payment_method_id, # Use the verified payment method ID - trial_period_days, # Use the trial period we obtained (90 for PILOT2025) - user_data.get('user_id') if user_data.get('user_id') else None, # Convert empty string to None - user_data.get('billing_interval', 'monthly') - ) - - # Update registration state if tracking + # Update registration state if state_id: try: await state_service.update_state_context(state_id, { 'subscription_id': result['subscription_id'], 'status': result['status'] }) - - await state_service.transition_state( - state_id, - RegistrationState.SUBSCRIPTION_CREATED - ) - - logger.info( - "Registration state updated after subscription creation", - extra={ - "state_id": state_id, - "subscription_id": result['subscription_id'] - } - ) + await state_service.transition_state(state_id, RegistrationState.SUBSCRIPTION_CREATED) except Exception as e: - logger.error("Failed to update registration state after subscription creation", - extra={ - "error": str(e), - "state_id": state_id - }, - exc_info=True) - - logger.info("Registration completed successfully after 3DS verification", + logger.warning(f"Failed to update registration state: {e}", extra={"state_id": state_id}) + + logger.info("Registration subscription created successfully", extra={ "email": user_data.get('email'), - "subscription_id": result['subscription_id'] + "subscription_id": result['subscription_id'], + "status": result['status'] }) - + return { "success": True, "subscription_id": result['subscription_id'], "customer_id": result['customer_id'], "payment_customer_id": result.get('payment_customer_id', result['customer_id']), "status": result['status'], - "plan_id": result.get('plan_id', result.get('plan')), + "plan_id": result.get('plan_id'), "payment_method_id": result.get('payment_method_id'), - "trial_period_days": result.get('trial_period_days'), + "trial_period_days": result.get('trial_period_days', 0), "current_period_end": result.get('current_period_end'), "state_id": state_id, - "message": "Registration completed successfully after 3DS verification" + "message": "Subscription created successfully" } - + except SetupIntentError as e: - logger.error("SetupIntent verification failed", - extra={ - "error": str(e), - "setup_intent_id": setup_intent_id, - "email": user_data.get('email') - }, + logger.error(f"SetupIntent verification failed: {e}", + extra={"setup_intent_id": setup_intent_id, "email": user_data.get('email')}, exc_info=True) - - # Mark registration as failed if state tracking if state_id: try: - await state_service.mark_registration_failed( - state_id, - f"SetupIntent verification failed: {str(e)}" - ) + await state_service.mark_registration_failed(state_id, f"Verification failed: {e}") except Exception: - pass # Don't fail main operation for state tracking failure - - raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail=f"SetupIntent verification failed: {str(e)}" - ) from e + pass + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=f"Payment verification failed: {e}") from e + except SubscriptionCreationFailed as e: - logger.error("Subscription creation failed after verification", - extra={ - "error": str(e), - "setup_intent_id": setup_intent_id, - "email": user_data.get('email') - }, + logger.error(f"Subscription creation failed: {e}", + extra={"setup_intent_id": setup_intent_id, "email": user_data.get('email')}, exc_info=True) - - # Mark registration as failed if state tracking if state_id: try: - await state_service.mark_registration_failed( - state_id, - f"Subscription creation failed: {str(e)}" - ) + await state_service.mark_registration_failed(state_id, f"Subscription failed: {e}") except Exception: - pass # Don't fail main operation for state tracking failure - - raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail=f"Subscription creation failed: {str(e)}" - ) from e + pass + raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"Subscription creation failed: {e}") from e + + except HTTPException: + raise + except Exception as e: - logger.error("Unexpected error in registration completion", - extra={ - "error": str(e), - "setup_intent_id": setup_intent_id, - "email": user_data.get('email') - }, + logger.error(f"Unexpected error: {e}", + extra={"setup_intent_id": setup_intent_id, "email": user_data.get('email')}, exc_info=True) - - # Mark registration as failed if state tracking if state_id: try: - await state_service.mark_registration_failed( - state_id, - f"Registration completion failed: {str(e)}" - ) + await state_service.mark_registration_failed(state_id, f"Registration failed: {e}") except Exception: - pass # Don't fail main operation for state tracking failure - - raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail=f"Registration completion failed: {str(e)}" - ) from e + pass + raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"Registration failed: {e}") from e @router.get("/registration-state/{state_id}", diff --git a/services/tenant/app/services/payment_service.py b/services/tenant/app/services/payment_service.py index 2e726eaa..14cf7357 100644 --- a/services/tenant/app/services/payment_service.py +++ b/services/tenant/app/services/payment_service.py @@ -12,7 +12,9 @@ from shared.exceptions.payment_exceptions import ( SubscriptionCreationFailed, SetupIntentError, PaymentServiceError, - SubscriptionUpdateFailed + SubscriptionUpdateFailed, + PaymentMethodError, + CustomerUpdateFailed ) from shared.utils.retry import retry_with_backoff @@ -138,7 +140,7 @@ class PaymentService: logger.error(f"Failed to set default payment method: {str(e)}, customer_id: {customer_id}, payment_method_id: {payment_method_id}", exc_info=True) raise PaymentServiceError(f"Default payment method update failed: {str(e)}") from e - + async def create_setup_intent_for_verification( self, customer_id: str, @@ -146,29 +148,27 @@ class PaymentService: metadata: Optional[Dict[str, Any]] = None ) -> Dict[str, Any]: """ - Atomic: Create SetupIntent for payment method verification - This is the FIRST step in secure registration flow - + Create SetupIntent for payment verification. + Args: customer_id: Stripe customer ID payment_method_id: Payment method ID to verify metadata: Additional metadata for tracking - + Returns: - SetupIntent result with verification requirements - + SetupIntent result for frontend confirmation + Raises: SetupIntentError: If SetupIntent creation fails """ try: - # Add registration-specific metadata full_metadata = metadata or {} full_metadata.update({ 'service': 'tenant', - 'operation': 'registration_payment_verification', + 'operation': 'verification_setup_intent', 'timestamp': datetime.now().isoformat() }) - + result = await retry_with_backoff( lambda: self.stripe_client.create_setup_intent_for_verification( customer_id, payment_method_id, full_metadata @@ -176,39 +176,306 @@ class PaymentService: max_retries=3, exceptions=(SetupIntentError,) ) - - logger.info("SetupIntent created for payment verification", + + logger.info("SetupIntent created for verification", setup_intent_id=result['setup_intent_id'], customer_id=customer_id, - payment_method_id=payment_method_id, - requires_action=result['requires_action']) - + requires_action=result['requires_action'], + status=result['status']) + return result - + except SetupIntentError as e: - logger.error(f"SetupIntent creation failed: {str(e)}, customer_id: {customer_id}, payment_method_id: {payment_method_id}", + logger.error(f"SetupIntent creation for verification failed: {str(e)}", + extra={"customer_id": customer_id, "payment_method_id": payment_method_id}, exc_info=True) raise except Exception as e: - logger.error(f"Unexpected error creating SetupIntent: {str(e)}, customer_id: {customer_id}, payment_method_id: {payment_method_id}", + logger.error(f"Unexpected error creating SetupIntent for verification: {str(e)}", + extra={"customer_id": customer_id, "payment_method_id": payment_method_id}, exc_info=True) raise SetupIntentError(f"Unexpected SetupIntent error: {str(e)}") from e + + # Alias for backward compatibility + async def create_setup_intent_for_registration( + self, + customer_id: str, + payment_method_id: str, + metadata: Optional[Dict[str, Any]] = None + ) -> Dict[str, Any]: + """ + Create standalone SetupIntent for payment verification during registration. + This is an alias for create_setup_intent_for_verification for backward compatibility. + + Args: + customer_id: Stripe customer ID + payment_method_id: Payment method ID to verify + metadata: Additional metadata for tracking + + Returns: + SetupIntent result for frontend confirmation + + Raises: + SetupIntentError: If SetupIntent creation fails + """ + return await self.create_setup_intent_for_verification(customer_id, payment_method_id, metadata) + + async def create_setup_intent( + self + ) -> Dict[str, Any]: + """ + Create a basic SetupIntent. + + Returns: + SetupIntent creation result + """ + try: + result = await retry_with_backoff( + lambda: self.stripe_client.create_setup_intent(), + max_retries=3, + exceptions=(SetupIntentError,) + ) + + logger.info("Basic SetupIntent created", + setup_intent_id=result['setup_intent_id'], + status=result['status']) + + return result + + except SetupIntentError as e: + logger.error(f"Basic SetupIntent creation failed: {str(e)}", + exc_info=True) + raise + except Exception as e: + logger.error(f"Unexpected error creating basic SetupIntent: {str(e)}", + exc_info=True) + raise SetupIntentError(f"Unexpected SetupIntent error: {str(e)}") from e + + async def get_setup_intent( + self, + setup_intent_id: str + ) -> Any: + """ + Get SetupIntent details. + + Args: + setup_intent_id: SetupIntent ID + + Returns: + SetupIntent object + """ + try: + result = await retry_with_backoff( + lambda: self.stripe_client.get_setup_intent(setup_intent_id), + max_retries=3, + exceptions=(SetupIntentError,) + ) + + logger.info("SetupIntent retrieved", + setup_intent_id=setup_intent_id) + + return result + + except SetupIntentError as e: + logger.error(f"SetupIntent retrieval failed: {str(e)}", + extra={"setup_intent_id": setup_intent_id}, + exc_info=True) + raise + except Exception as e: + logger.error(f"Unexpected error retrieving SetupIntent: {str(e)}", + extra={"setup_intent_id": setup_intent_id}, + exc_info=True) + raise SetupIntentError(f"Unexpected SetupIntent error: {str(e)}") from e + + async def create_payment_intent( + self, + amount: float, + currency: str, + customer_id: str, + payment_method_id: str + ) -> Dict[str, Any]: + """ + Create a PaymentIntent for one-time payments. + + Args: + amount: Payment amount + currency: Currency code + customer_id: Customer ID + payment_method_id: Payment method ID + + Returns: + PaymentIntent creation result + """ + try: + result = await retry_with_backoff( + lambda: self.stripe_client.create_payment_intent( + amount, currency, customer_id, payment_method_id + ), + max_retries=3, + exceptions=(PaymentVerificationError,) + ) + + logger.info("PaymentIntent created", + payment_intent_id=result['payment_intent_id'], + status=result['status']) + + return result + + except PaymentVerificationError as e: + logger.error(f"PaymentIntent creation failed: {str(e)}", + extra={ + "amount": amount, + "currency": currency, + "customer_id": customer_id, + "payment_method_id": payment_method_id + }, + exc_info=True) + raise + except Exception as e: + logger.error(f"Unexpected error creating PaymentIntent: {str(e)}", + extra={ + "amount": amount, + "currency": currency, + "customer_id": customer_id, + "payment_method_id": payment_method_id + }, + exc_info=True) + raise PaymentVerificationError(f"Unexpected payment error: {str(e)}") from e + + async def create_setup_intent_for_verification( + self, + customer_id: str, + payment_method_id: str, + metadata: Optional[Dict[str, Any]] = None + ) -> Dict[str, Any]: + """ + Create SetupIntent for registration payment verification. + + NEW ARCHITECTURE: Only creates SetupIntent, no subscription. + Subscription is created after verification completes. + + Args: + customer_id: Stripe customer ID + payment_method_id: Payment method ID to verify + metadata: Additional metadata for tracking + + Returns: + SetupIntent result for frontend confirmation + + Raises: + SetupIntentError: If SetupIntent creation fails + """ + try: + full_metadata = metadata or {} + full_metadata.update({ + 'service': 'tenant', + 'operation': 'registration_setup_intent', + 'timestamp': datetime.now().isoformat() + }) + + result = await retry_with_backoff( + lambda: self.stripe_client.create_setup_intent_for_registration( + customer_id, payment_method_id, full_metadata + ), + max_retries=3, + exceptions=(SetupIntentError,) + ) + + logger.info("SetupIntent created for registration", + setup_intent_id=result['setup_intent_id'], + customer_id=customer_id, + requires_action=result['requires_action'], + status=result['status']) + + return result + + except SetupIntentError as e: + logger.error(f"SetupIntent creation failed: {str(e)}", + extra={"customer_id": customer_id, "payment_method_id": payment_method_id}, + exc_info=True) + raise + except Exception as e: + logger.error(f"Unexpected error creating SetupIntent: {str(e)}", + extra={"customer_id": customer_id, "payment_method_id": payment_method_id}, + exc_info=True) + raise SetupIntentError(f"Unexpected SetupIntent error: {str(e)}") from e + + async def create_subscription_after_verification( + self, + customer_id: str, + price_id: str, + payment_method_id: str, + trial_period_days: Optional[int] = None, + metadata: Optional[Dict[str, Any]] = None + ) -> Dict[str, Any]: + """ + Create subscription AFTER SetupIntent verification succeeds. + + NEW ARCHITECTURE: Called only after payment verification completes. + + Args: + customer_id: Stripe customer ID + price_id: Stripe price ID for the plan + payment_method_id: Verified payment method ID + trial_period_days: Optional trial period in days + metadata: Additional metadata + + Returns: + Subscription creation result + + Raises: + SubscriptionCreationFailed: If subscription creation fails + """ + try: + full_metadata = metadata or {} + full_metadata.update({ + 'service': 'tenant', + 'operation': 'registration_subscription', + 'timestamp': datetime.now().isoformat() + }) + + result = await retry_with_backoff( + lambda: self.stripe_client.create_subscription_after_verification( + customer_id, price_id, payment_method_id, trial_period_days, full_metadata + ), + max_retries=3, + exceptions=(SubscriptionCreationFailed,) + ) + + logger.info("Subscription created after verification", + subscription_id=result['subscription_id'], + customer_id=customer_id, + status=result['status'], + trial_period_days=trial_period_days) + + return result + + except SubscriptionCreationFailed as e: + logger.error(f"Subscription creation failed: {str(e)}", + extra={"customer_id": customer_id, "price_id": price_id}, + exc_info=True) + raise + except Exception as e: + logger.error(f"Unexpected error creating subscription: {str(e)}", + extra={"customer_id": customer_id, "price_id": price_id}, + exc_info=True) + raise SubscriptionCreationFailed(f"Unexpected subscription error: {str(e)}") from e - async def verify_setup_intent_status( + async def verify_setup_intent( self, setup_intent_id: str ) -> Dict[str, Any]: """ - Atomic: Verify SetupIntent status after frontend confirmation - + Verify SetupIntent status after frontend confirmation. + Args: setup_intent_id: SetupIntent ID to verify - + Returns: - SetupIntent verification result - + SetupIntent verification result with 'verified' boolean + Raises: - SetupIntentError: If verification fails + SetupIntentError: If retrieval fails """ try: result = await retry_with_backoff( @@ -216,26 +483,22 @@ class PaymentService: max_retries=3, exceptions=(SetupIntentError,) ) - + logger.info("SetupIntent verification completed", setup_intent_id=setup_intent_id, status=result['status'], verified=result.get('verified', False)) - - # Check if verification was successful - if not result.get('verified', False): - error_msg = result.get('last_setup_error', 'Verification failed') - logger.error(f"SetupIntent verification failed: {error_msg}, setup_intent_id: {setup_intent_id}") - raise SetupIntentError(f"SetupIntent verification failed: {error_msg}") - + return result - + except SetupIntentError as e: - logger.error(f"SetupIntent verification failed: {str(e)}, setup_intent_id: {setup_intent_id}", + logger.error(f"SetupIntent verification failed: {str(e)}", + extra={"setup_intent_id": setup_intent_id}, exc_info=True) raise except Exception as e: - logger.error(f"Unexpected error verifying SetupIntent: {str(e)}, setup_intent_id: {setup_intent_id}", + logger.error(f"Unexpected error verifying SetupIntent: {str(e)}", + extra={"setup_intent_id": setup_intent_id}, exc_info=True) raise SetupIntentError(f"Unexpected verification error: {str(e)}") from e @@ -884,7 +1147,7 @@ class PaymentService: exc_info=True) raise PaymentServiceError(f"Subscription completion failed: {str(e)}") from e - async def verify_setup_intent( + async def verify_setup_intent_status( self, setup_intent_id: str ) -> Dict[str, Any]: @@ -956,6 +1219,86 @@ class PaymentService: exc_info=True) raise SubscriptionUpdateFailed(f"Failed to update payment method: {str(e)}") from e + async def attach_payment_method_to_customer( + self, + customer_id: str, + payment_method_id: str + ) -> Any: + """ + Attach a payment method to a customer + + Args: + customer_id: Stripe customer ID + payment_method_id: Payment method ID + + Returns: + Updated payment method object + """ + try: + logger.info("Attaching payment method to customer", + customer_id=customer_id, + payment_method_id=payment_method_id) + + payment_method = await retry_with_backoff( + lambda: self.stripe_client.attach_payment_method_to_customer( + customer_id, + payment_method_id + ), + max_retries=3, + exceptions=(PaymentMethodError,) + ) + + logger.info("Payment method attached to customer successfully", + customer_id=customer_id, + payment_method_id=payment_method.id) + + return payment_method + + except Exception as e: + logger.error(f"Failed to attach payment method to customer: {str(e)}, customer_id: {customer_id}", + exc_info=True) + raise PaymentMethodError(f"Failed to attach payment method: {str(e)}") from e + + async def set_customer_default_payment_method( + self, + customer_id: str, + payment_method_id: str + ) -> Any: + """ + Set a payment method as the customer's default payment method + + Args: + customer_id: Stripe customer ID + payment_method_id: Payment method ID + + Returns: + Updated customer object + """ + try: + logger.info("Setting default payment method for customer", + customer_id=customer_id, + payment_method_id=payment_method_id) + + customer = await retry_with_backoff( + lambda: self.stripe_client.set_customer_default_payment_method( + customer_id, + payment_method_id + ), + max_retries=3, + exceptions=(CustomerUpdateFailed,) + ) + + logger.info("Default payment method set for customer successfully", + customer_id=customer.id, + payment_method_id=payment_method_id) + + return customer + + except Exception as e: + logger.error(f"Failed to set default payment method for customer: {str(e)}, customer_id: {customer_id}", + exc_info=True) + raise CustomerUpdateFailed(f"Failed to set default payment method: {str(e)}") from e + # Singleton instance for dependency injection payment_service = PaymentService() \ No newline at end of file diff --git a/services/tenant/app/services/subscription_orchestration_service.py b/services/tenant/app/services/subscription_orchestration_service.py index 01994a5e..1ff0cb4f 100644 --- a/services/tenant/app/services/subscription_orchestration_service.py +++ b/services/tenant/app/services/subscription_orchestration_service.py @@ -5,7 +5,7 @@ This service orchestrates complex workflows involving multiple services """ import structlog -from typing import Dict, Any, Optional +from typing import Dict, Any, Optional, List from datetime import datetime, timezone from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import Session @@ -14,6 +14,7 @@ from app.services.subscription_service import SubscriptionService from app.services.payment_service import PaymentService from app.services.coupon_service import CouponService from app.services.tenant_service import EnhancedTenantService +from app.models.tenants import Subscription from app.core.config import settings from shared.database.exceptions import DatabaseError, ValidationError from shared.database.base import create_database_manager @@ -1619,55 +1620,58 @@ class SubscriptionOrchestrationService: 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. - + Create payment customer and SetupIntent for registration. + + NEW ARCHITECTURE: Only creates customer + SetupIntent here. + Subscription is created AFTER SetupIntent verification completes. + + Flow: + 1. Create Stripe customer + 2. Handle coupon (get trial days) + 3. Create SetupIntent for payment verification + 4. Return SetupIntent to frontend for 3DS handling + 5. (Later) complete_registration_subscription() creates subscription + Args: - user_data: User data (email, full_name, etc.) - NO user_id required + user_data: User data (email, full_name, etc.) 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 - + Dictionary with SetupIntent data for frontend + Raises: Exception: If payment setup fails """ try: - logger.info("Starting registration payment setup (pre-user-creation)", + logger.info("Starting registration payment setup", 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 + # Step 1: Create payment customer email = user_data.get('email') name = user_data.get('full_name') metadata = { - 'registration_flow': 'pre_user_creation', + 'registration_flow': 'setup_intent_first', + 'plan_id': plan_id, + 'billing_interval': billing_interval, 'timestamp': datetime.now(timezone.utc).isoformat() } customer = await self.payment_service.create_customer(email, name, metadata) - logger.info("Payment customer created for registration", + logger.info("Customer created for registration", customer_id=customer.id, - email=user_data.get('email')) + email=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", + logger.info("Validating coupon for registration", coupon_code=coupon_code, - email=user_data.get('email')) + email=email) coupon_service = CouponService(self.db_session) success, discount_applied, error = await coupon_service.redeem_coupon( @@ -1677,82 +1681,55 @@ class SubscriptionOrchestrationService: ) 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", + logger.info("Coupon validated for registration", coupon_code=coupon_code, trial_period_days=trial_period_days) else: - logger.warning("Failed to redeem coupon for registration, continuing without it", + logger.warning("Failed to validate coupon, continuing without it", coupon_code=coupon_code, error=error) - # Step 3: Create subscription/SetupIntent - logger.info("Creating subscription/SetupIntent for registration", + # Step 3: Create SetupIntent (NO subscription yet!) + logger.info("Creating SetupIntent for registration", customer_id=customer.id, - plan_id=plan_id, payment_method_id=payment_method_id) - # Get the Stripe price ID for this plan - price_id = self.payment_service._get_stripe_price_id(plan_id, billing_interval) - - subscription_result = await self.payment_service.create_subscription_with_verified_payment( + setup_result = await self.payment_service.create_setup_intent_for_registration( customer.id, - price_id, payment_method_id, - trial_period_days if trial_period_days > 0 else None, - billing_interval + { + 'purpose': 'registration', + 'plan_id': plan_id, + 'billing_interval': billing_interval, + 'trial_period_days': str(trial_period_days), + 'coupon_code': coupon_code or '' + } ) - # 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'), - subscription_id=subscription_result.get('subscription_id')) + logger.info("SetupIntent created for registration", + setup_intent_id=setup_result.get('setup_intent_id'), + requires_action=setup_result.get('requires_action'), + status=setup_result.get('status')) - # Return the SetupIntent data for frontend to handle 3DS - # Note: subscription_id is included because for trial subscriptions, - # the subscription is already created in 'trialing' status even though - # the SetupIntent requires 3DS verification for future payments - return { - "requires_action": True, - "action_type": subscription_result.get('action_type') or 'use_stripe_sdk', - "client_secret": subscription_result.get('client_secret'), - "setup_intent_id": subscription_result.get('setup_intent_id'), - "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": 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" - } + # Return result for frontend + # Frontend will call complete_registration_subscription() after 3DS + return { + "requires_action": setup_result.get('requires_action', True), + "action_type": "use_stripe_sdk", + "client_secret": setup_result.get('client_secret'), + "setup_intent_id": setup_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_code": coupon_code, + "email": email, + "full_name": name, + "message": "Payment verification required" if setup_result.get('requires_action') else "Payment verified" + } except Exception as e: logger.error("Registration payment setup failed", @@ -1766,41 +1743,143 @@ class SubscriptionOrchestrationService: 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. - + Verify SetupIntent status for registration completion. + 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", + logger.info("Verifying SetupIntent for registration", 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", + logger.info("SetupIntent verification result", setup_intent_id=setup_intent_id, - status=verification_result.get('status')) + status=verification_result.get('status'), + verified=verification_result.get('verified')) return verification_result except Exception as e: - logger.error("SetupIntent verification failed for registration", + logger.error("SetupIntent verification failed", setup_intent_id=setup_intent_id, error=str(e), exc_info=True) raise + async def complete_registration_subscription( + self, + setup_intent_id: str, + customer_id: str, + plan_id: str, + payment_method_id: str, + billing_interval: str = "monthly", + trial_period_days: int = 0, + user_id: Optional[str] = None + ) -> Dict[str, Any]: + """ + Create subscription AFTER SetupIntent verification succeeds. + + NEW ARCHITECTURE: This is called AFTER 3DS verification completes. + The subscription is created here, not during payment setup. + + Args: + setup_intent_id: Verified SetupIntent ID + customer_id: Stripe customer ID + plan_id: Subscription plan ID + payment_method_id: Verified payment method ID + billing_interval: Billing interval (monthly/yearly) + trial_period_days: Trial period in days (from coupon) + user_id: Optional user ID if user already created + + Returns: + Dictionary with subscription details + + Raises: + Exception: If subscription creation fails + """ + try: + logger.info("Creating subscription after verification", + setup_intent_id=setup_intent_id, + customer_id=customer_id, + plan_id=plan_id, + trial_period_days=trial_period_days) + + # Verify SetupIntent is successful + verification = await self.payment_service.verify_setup_intent(setup_intent_id) + if not verification.get('verified'): + raise ValidationError( + f"SetupIntent not verified. Status: {verification.get('status')}" + ) + + # Get actual customer_id and payment_method_id from verification + actual_customer_id = verification.get('customer_id') or customer_id + actual_payment_method_id = verification.get('payment_method_id') or payment_method_id + + # Get price ID for the plan + price_id = self.payment_service._get_stripe_price_id(plan_id, billing_interval) + + # Create subscription in Stripe + subscription_result = await self.payment_service.create_subscription_after_verification( + actual_customer_id, + price_id, + actual_payment_method_id, + trial_period_days if trial_period_days > 0 else None, + { + 'plan_id': plan_id, + 'billing_interval': billing_interval, + 'created_via': 'registration_flow', + 'setup_intent_id': setup_intent_id + } + ) + + logger.info("Subscription created after verification", + subscription_id=subscription_result.get('subscription_id'), + status=subscription_result.get('status'), + trial_period_days=trial_period_days) + + # Create local subscription record (without tenant_id for now) + subscription_record = await self.subscription_service.create_tenant_independent_subscription_record( + subscription_result['subscription_id'], + actual_customer_id, + plan_id, + subscription_result['status'], + trial_period_days, + billing_interval, + user_id + ) + + logger.info("Subscription record created", + subscription_id=subscription_result['subscription_id'], + record_id=str(subscription_record.id) if subscription_record else None) + + return { + 'subscription_id': subscription_result['subscription_id'], + 'customer_id': actual_customer_id, + 'payment_customer_id': actual_customer_id, + 'payment_method_id': actual_payment_method_id, + 'status': subscription_result['status'], + 'plan_id': plan_id, + 'trial_period_days': trial_period_days, + 'current_period_end': subscription_result.get('current_period_end'), + 'message': 'Subscription created successfully' + } + + except Exception as e: + logger.error("Subscription creation after verification failed", + setup_intent_id=setup_intent_id, + customer_id=customer_id, + error=str(e), + exc_info=True) + raise + async def validate_plan_upgrade( self, tenant_id: str, @@ -1878,10 +1957,14 @@ class SubscriptionOrchestrationService: trial_period_days: Optional[int] = None ) -> Dict[str, Any]: """ - Update an existing subscription with a verified payment method + Update an existing trial subscription with a verified payment method - This is used when we already have a trial subscription and just need to - attach the verified payment method to it. + This is used when we already have a trial subscription (created during registration) + and just need to attach the verified payment method to it after 3DS verification. + + For trial subscriptions, the payment method should be: + 1. Attached to the customer (for trial period) + 2. Set as default payment method on the subscription (for future billing) Args: subscription_id: Stripe subscription ID @@ -1893,10 +1976,11 @@ class SubscriptionOrchestrationService: Dictionary with updated subscription details """ try: - logger.info("Updating existing subscription with verified payment method", + logger.info("Updating existing trial subscription with verified payment method", subscription_id=subscription_id, customer_id=customer_id, - payment_method_id=payment_method_id) + payment_method_id=payment_method_id, + trial_period_days=trial_period_days) # First, verify the subscription exists and get its current status existing_subscription = await self.subscription_service.get_subscription_by_provider_id(subscription_id) @@ -1904,19 +1988,46 @@ class SubscriptionOrchestrationService: if not existing_subscription: raise SubscriptionNotFound(f"Subscription {subscription_id} not found") - # Update the subscription in Stripe with the verified payment method + # For trial subscriptions, we need to: + # 1. Ensure payment method is attached to customer + # 2. Set it as default payment method on subscription + + # Step 1: Attach payment method to customer (if not already attached) + try: + await self.payment_service.attach_payment_method_to_customer( + customer_id, + payment_method_id + ) + logger.info("Payment method attached to customer for trial subscription", + customer_id=customer_id, + payment_method_id=payment_method_id) + except Exception as e: + logger.warning("Payment method may already be attached to customer", + customer_id=customer_id, + payment_method_id=payment_method_id, + error=str(e)) + + # Step 2: Set payment method as default on subscription stripe_subscription = await self.payment_service.update_subscription_payment_method( subscription_id, payment_method_id ) + # Step 3: Also set as default payment method on customer for future invoices + await self.payment_service.set_customer_default_payment_method( + customer_id, + payment_method_id + ) + # Update our local subscription record await self.subscription_service.update_subscription_status( existing_subscription.tenant_id, stripe_subscription.status, { 'current_period_start': datetime.fromtimestamp(stripe_subscription.current_period_start), - 'current_period_end': datetime.fromtimestamp(stripe_subscription.current_period_end) + 'current_period_end': datetime.fromtimestamp(stripe_subscription.current_period_end), + 'payment_method_verified': True, + 'payment_method_id': payment_method_id } ) @@ -1940,14 +2051,17 @@ class SubscriptionOrchestrationService: 'verification': { 'verified': True, 'customer_id': customer_id, - 'payment_method_id': payment_method_id - } + 'payment_method_id': payment_method_id, + 'trial_period_days': trial_period_days + }, + 'trial_preserved': True, + 'payment_method_updated': True } except Exception as e: - logger.error("Failed to update subscription with verified payment", + logger.error("Failed to update trial subscription with verified payment", subscription_id=subscription_id, customer_id=customer_id, error=str(e), exc_info=True) - raise SubscriptionUpdateFailed(f"Failed to update subscription: {str(e)}") + raise SubscriptionUpdateFailed(f"Failed to update trial subscription: {str(e)}") diff --git a/shared/clients/stripe_client.py b/shared/clients/stripe_client.py index 22181ea7..76b3acd2 100755 --- a/shared/clients/stripe_client.py +++ b/shared/clients/stripe_client.py @@ -15,7 +15,9 @@ from shared.exceptions.payment_exceptions import ( PaymentVerificationError, SubscriptionCreationFailed, SetupIntentError, - SubscriptionUpdateFailed + SubscriptionUpdateFailed, + PaymentMethodError, + CustomerUpdateFailed ) # Configure logging @@ -42,38 +44,75 @@ class StripeClient(PaymentProvider): metadata: Optional[Dict[str, Any]] = None ) -> Dict[str, Any]: """ - Atomic: Create SetupIntent for payment method verification - This is the FIRST step in secure registration flow - + Create standalone SetupIntent for payment verification during registration. + + This is the ONLY step that happens before 3DS verification completes. + NO subscription is created here - subscription is created AFTER verification. + + Flow: + 1. Frontend collects payment method + 2. Backend creates customer + SetupIntent (this method) + 3. Frontend confirms SetupIntent (handles 3DS if needed) + 4. Backend creates subscription AFTER SetupIntent succeeds + Args: customer_id: Stripe customer ID payment_method_id: Payment method ID to verify metadata: Additional metadata for tracking - + Returns: - SetupIntent result with verification requirements - + SetupIntent result for frontend confirmation + Raises: SetupIntentError: If SetupIntent creation fails """ try: + # First attach payment method to customer + try: + stripe.PaymentMethod.attach( + payment_method_id, + customer=customer_id + ) + logger.info( + "Payment method attached to customer", + extra={"payment_method_id": payment_method_id, "customer_id": customer_id} + ) + except stripe.error.InvalidRequestError as e: + # Payment method might already be attached + if "already been attached" not in str(e): + raise + logger.info( + "Payment method already attached to customer", + extra={"payment_method_id": payment_method_id, "customer_id": customer_id} + ) + + # Set as default payment method on customer + stripe.Customer.modify( + customer_id, + invoice_settings={'default_payment_method': payment_method_id} + ) + + # Create SetupIntent for verification setup_intent_params = { 'customer': customer_id, 'payment_method': payment_method_id, - 'usage': 'off_session', - 'confirm': False, # Frontend must confirm to handle 3DS - 'idempotency_key': f"setup_intent_{uuid.uuid4()}", + 'usage': 'off_session', # For future recurring payments + 'confirm': True, # Confirm immediately - this triggers 3DS check + 'idempotency_key': f"setup_intent_reg_{uuid.uuid4()}", 'metadata': metadata or { 'purpose': 'registration_payment_verification', - 'timestamp': datetime.now().isoformat() + 'timestamp': datetime.now(timezone.utc).isoformat() + }, + 'automatic_payment_methods': { + 'enabled': True, + 'allow_redirects': 'never' } } - - # Create SetupIntent without confirmation + setup_intent = stripe.SetupIntent.create(**setup_intent_params) - + logger.info( - "SetupIntent created for payment verification", + "SetupIntent created for verification", extra={ "setup_intent_id": setup_intent.id, "status": setup_intent.status, @@ -81,25 +120,24 @@ class StripeClient(PaymentProvider): "payment_method_id": payment_method_id } ) - - # Always return SetupIntent for frontend confirmation - # Frontend will handle 3DS if required - # Note: With confirm=False, the SetupIntent will have status 'requires_confirmation' - # The actual 3DS requirement is only determined after frontend confirmation + + # Check if 3DS is required + requires_action = setup_intent.status in ['requires_action', 'requires_confirmation'] + return { 'setup_intent_id': setup_intent.id, 'client_secret': setup_intent.client_secret, 'status': setup_intent.status, - 'requires_action': True, # Always require frontend confirmation for 3DS support + 'requires_action': requires_action, 'customer_id': customer_id, 'payment_method_id': payment_method_id, 'created': setup_intent.created, - 'metadata': setup_intent.metadata + 'metadata': dict(setup_intent.metadata) if setup_intent.metadata else {} } - + except stripe.error.StripeError as e: logger.error( - "Stripe SetupIntent creation failed", + "SetupIntent creation for verification failed", extra={ "error": str(e), "error_type": type(e).__name__, @@ -111,7 +149,7 @@ class StripeClient(PaymentProvider): raise SetupIntentError(f"SetupIntent creation failed: {str(e)}") from e except Exception as e: logger.error( - "Unexpected error creating SetupIntent", + "Unexpected error creating SetupIntent for verification", extra={ "error": str(e), "customer_id": customer_id, @@ -120,7 +158,226 @@ class StripeClient(PaymentProvider): exc_info=True ) raise SetupIntentError(f"Unexpected SetupIntent error: {str(e)}") from e - + + # Alias for backward compatibility + async def create_setup_intent_for_registration( + self, + customer_id: str, + payment_method_id: str, + metadata: Optional[Dict[str, Any]] = None + ) -> Dict[str, Any]: + """ + Create standalone SetupIntent for payment verification during registration. + This is an alias for create_setup_intent_for_verification for backward compatibility. + + Args: + customer_id: Stripe customer ID + payment_method_id: Payment method ID to verify + metadata: Additional metadata for tracking + + Returns: + SetupIntent result for frontend confirmation + + Raises: + SetupIntentError: If SetupIntent creation fails + """ + return await self.create_setup_intent_for_verification( + customer_id, payment_method_id, metadata + ) + + async def create_subscription_after_verification( + self, + customer_id: str, + price_id: str, + payment_method_id: str, + trial_period_days: Optional[int] = None, + metadata: Optional[Dict[str, Any]] = None + ) -> Dict[str, Any]: + """ + Create subscription AFTER SetupIntent verification succeeds. + + This is the SECOND step - only called after frontend confirms SetupIntent. + The payment method is already verified at this point. + + STRIPE BEST PRACTICES FOR TRIALS: + - For trial subscriptions: attach payment method to CUSTOMER (not subscription) + - Use off_session=True for future merchant-initiated charges + - Trial subscriptions generate $0 invoices initially + - Payment method is charged automatically when trial ends + + Args: + customer_id: Stripe customer ID + price_id: Stripe price ID for the plan + payment_method_id: Verified payment method ID + trial_period_days: Optional trial period in days + metadata: Additional metadata + + Returns: + Subscription creation result + + Raises: + SubscriptionCreationFailed: If subscription creation fails + """ + try: + has_trial = trial_period_days and trial_period_days > 0 + + # Build base metadata + base_metadata = metadata or {} + base_metadata.update({ + 'purpose': 'registration_subscription', + 'created_after_verification': 'true', + 'timestamp': datetime.now(timezone.utc).isoformat() + }) + + # STRIPE BEST PRACTICE: For trial subscriptions, attach payment method + # to CUSTOMER (not subscription) to avoid immediate charges + if has_trial: + # Set payment method as customer's default (already done in SetupIntent, + # but ensure it's set for subscription billing) + stripe.Customer.modify( + customer_id, + invoice_settings={'default_payment_method': payment_method_id} + ) + + subscription_params = { + 'customer': customer_id, + 'items': [{'price': price_id}], + 'trial_period_days': trial_period_days, + 'off_session': True, # Future charges are merchant-initiated + 'idempotency_key': f"sub_trial_{uuid.uuid4()}", + 'payment_settings': { + 'payment_method_options': { + 'card': { + 'request_three_d_secure': 'automatic' + } + }, + 'save_default_payment_method': 'on_subscription' + }, + 'metadata': { + **base_metadata, + 'trial_subscription': 'true', + 'trial_period_days': str(trial_period_days), + 'payment_strategy': 'customer_default_method' + } + } + + logger.info( + "Creating TRIAL subscription (payment method on customer)", + extra={ + "customer_id": customer_id, + "price_id": price_id, + "trial_period_days": trial_period_days + } + ) + else: + # Non-trial: attach payment method directly to subscription + subscription_params = { + 'customer': customer_id, + 'items': [{'price': price_id}], + 'default_payment_method': payment_method_id, + 'idempotency_key': f"sub_immediate_{uuid.uuid4()}", + 'payment_settings': { + 'payment_method_options': { + 'card': { + 'request_three_d_secure': 'automatic' + } + }, + 'save_default_payment_method': 'on_subscription' + }, + 'metadata': { + **base_metadata, + 'trial_subscription': 'false', + 'payment_strategy': 'subscription_default_method' + } + } + + logger.info( + "Creating NON-TRIAL subscription (payment method on subscription)", + extra={ + "customer_id": customer_id, + "price_id": price_id + } + ) + + # Create subscription + subscription = stripe.Subscription.create(**subscription_params) + + # Extract timestamps + current_period_start = self._extract_timestamp( + getattr(subscription, 'current_period_start', None) + ) + current_period_end = self._extract_timestamp( + getattr(subscription, 'current_period_end', None) + ) + + # Verify trial was set correctly for trial subscriptions + if has_trial: + if subscription.status != 'trialing': + logger.warning( + "Trial subscription created but status is not 'trialing'", + extra={ + "subscription_id": subscription.id, + "status": subscription.status, + "trial_period_days": trial_period_days, + "trial_end": getattr(subscription, 'trial_end', None) + } + ) + else: + logger.info( + "Trial subscription created successfully with $0 initial invoice", + extra={ + "subscription_id": subscription.id, + "status": subscription.status, + "trial_period_days": trial_period_days, + "trial_end": getattr(subscription, 'trial_end', None) + } + ) + else: + logger.info( + "Subscription created successfully", + extra={ + "subscription_id": subscription.id, + "customer_id": customer_id, + "status": subscription.status + } + ) + + return { + 'subscription_id': subscription.id, + 'customer_id': customer_id, + 'status': subscription.status, + 'current_period_start': current_period_start, + 'current_period_end': current_period_end, + 'trial_period_days': trial_period_days, + 'trial_end': getattr(subscription, 'trial_end', None), + 'created': getattr(subscription, 'created', None), + 'metadata': dict(subscription.metadata) if subscription.metadata else {} + } + + except stripe.error.StripeError as e: + logger.error( + "Subscription creation after verification failed", + extra={ + "error": str(e), + "error_type": type(e).__name__, + "customer_id": customer_id, + "price_id": price_id + }, + exc_info=True + ) + raise SubscriptionCreationFailed(f"Subscription creation failed: {str(e)}") from e + except Exception as e: + logger.error( + "Unexpected error creating subscription after verification", + extra={ + "error": str(e), + "customer_id": customer_id, + "price_id": price_id + }, + exc_info=True + ) + raise SubscriptionCreationFailed(f"Unexpected subscription error: {str(e)}") from e + async def verify_setup_intent_status( self, setup_intent_id: str @@ -159,7 +416,8 @@ class StripeClient(PaymentProvider): 'payment_method_id': setup_intent.payment_method, 'verified': True, 'requires_action': False, - 'last_setup_error': setup_intent.last_setup_error + 'last_setup_error': setup_intent.last_setup_error, + 'metadata': dict(setup_intent.metadata) if setup_intent.metadata else {} } elif setup_intent.status == 'requires_action': return { @@ -1347,6 +1605,102 @@ class StripeClient(PaymentProvider): ) raise SubscriptionUpdateFailed(f"Stripe API failed: {str(e)}") from e + async def attach_payment_method_to_customer( + self, + customer_id: str, + payment_method_id: str + ) -> Any: + """ + Attach a payment method to a customer + + Args: + customer_id: Stripe customer ID + payment_method_id: Payment method ID + + Returns: + Updated payment method object + + Raises: + PaymentMethodError: If the attachment fails + """ + try: + logger.info("Attaching payment method to customer in Stripe", + customer_id=customer_id, + payment_method_id=payment_method_id) + + # Attach payment method to customer + payment_method = stripe.PaymentMethod.attach( + payment_method_id, + customer=customer_id + ) + + logger.info("Payment method attached to customer in Stripe", + customer_id=customer_id, + payment_method_id=payment_method.id) + + return payment_method + + except Exception as e: + logger.error( + "Failed to attach payment method to customer in Stripe", + extra={ + "customer_id": customer_id, + "payment_method_id": payment_method_id, + "error": str(e) + }, + exc_info=True + ) + raise PaymentMethodError(f"Stripe API failed: {str(e)}") from e + + async def set_customer_default_payment_method( + self, + customer_id: str, + payment_method_id: str + ) -> Any: + """ + Set a payment method as the customer's default payment method + + Args: + customer_id: Stripe customer ID + payment_method_id: Payment method ID + + Returns: + Updated customer object + + Raises: + CustomerUpdateFailed: If the update fails + """ + try: + logger.info("Setting default payment method for customer in Stripe", + customer_id=customer_id, + payment_method_id=payment_method_id) + + # Set default payment method on customer + customer = stripe.Customer.modify( + customer_id, + invoice_settings={ + 'default_payment_method': payment_method_id + } + ) + + logger.info("Default payment method set for customer in Stripe", + customer_id=customer.id, + payment_method_id=payment_method_id) + + return customer + + except Exception as e: + logger.error( + "Failed to set default payment method for customer in Stripe", + extra={ + "customer_id": customer_id, + "payment_method_id": payment_method_id, + "error": str(e) + }, + exc_info=True + ) + raise CustomerUpdateFailed(f"Stripe API failed: {str(e)}") from e + # Singleton instance for dependency injection stripe_client = StripeClient() \ No newline at end of file diff --git a/shared/exceptions/payment_exceptions.py b/shared/exceptions/payment_exceptions.py index c8f960f3..01662b6f 100644 --- a/shared/exceptions/payment_exceptions.py +++ b/shared/exceptions/payment_exceptions.py @@ -67,4 +67,14 @@ class SubscriptionUpdateFailed(PaymentException): class PaymentServiceError(PaymentException): """General payment service error""" def __init__(self, message: str = "Payment service error"): + super().__init__(message) + +class PaymentMethodError(PaymentException): + """Exception raised when payment method operations fail""" + def __init__(self, message: str = "Payment method operation failed"): + super().__init__(message) + +class CustomerUpdateFailed(PaymentException): + """Exception raised when customer update operations fail""" + def __init__(self, message: str = "Customer update failed"): super().__init__(message) \ No newline at end of file