Add subcription feature 4

This commit is contained in:
Urtzi Alfaro
2026-01-15 22:06:36 +01:00
parent b674708a4c
commit 483a9f64cd
10 changed files with 1209 additions and 1390 deletions

View File

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

View File

@@ -31,6 +31,9 @@ export interface UserRegistration {
billing_cycle?: 'monthly' | 'yearly' | null; // Default: "monthly" - Billing cycle preference billing_cycle?: 'monthly' | 'yearly' | null; // Default: "monthly" - Billing cycle preference
payment_method_id?: string | null; // Stripe payment method ID payment_method_id?: string | null; // Stripe payment method ID
coupon_code?: string | null; // Promotional coupon code for discounts/trial extensions 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 // GDPR Consent fields
terms_accepted?: boolean; // Default: true - Accept terms of service terms_accepted?: boolean; // Default: true - Accept terms of service
privacy_accepted?: boolean; // Default: true - Accept privacy policy privacy_accepted?: boolean; // Default: true - Accept privacy policy
@@ -68,6 +71,7 @@ export interface RegistrationStartResponse {
plan_id?: string | null; // Plan ID plan_id?: string | null; // Plan ID
payment_method_id?: string | null; // Payment method ID payment_method_id?: string | null; // Payment method ID
billing_cycle?: string | null; // Billing cycle 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 email?: string | null; // User email
state_id?: string | null; // Registration state ID for tracking state_id?: string | null; // Registration state ID for tracking
message?: string | null; // Message explaining what needs to be done message?: string | null; // Message explaining what needs to be done

View File

@@ -264,7 +264,7 @@ export const PaymentStep: React.FC<PaymentStepProps> = ({
// Store payment method ID for use after 3DS completion // Store payment method ID for use after 3DS completion
setPendingPaymentMethodId(paymentMethod.id); 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({ updateRegistrationState({
paymentSetup: { paymentSetup: {
success: false, success: false,
@@ -272,6 +272,7 @@ export const PaymentStep: React.FC<PaymentStepProps> = ({
subscriptionId: '', subscriptionId: '',
paymentMethodId: paymentMethod.id, paymentMethodId: paymentMethod.id,
planId: registrationState.subscription.planId, planId: registrationState.subscription.planId,
trialPeriodDays: paymentSetupResult.trial_period_days ?? (registrationState.subscription.useTrial ? 90 : 0),
}, },
}); });
@@ -353,6 +354,7 @@ export const PaymentStep: React.FC<PaymentStepProps> = ({
: setupIntent.payment_method?.id || pendingPaymentMethodId; : setupIntent.payment_method?.id || pendingPaymentMethodId;
// Complete registration with verified SetupIntent using React Query mutation // Complete registration with verified SetupIntent using React Query mutation
// Send coupon_code to backend for trial period calculation
const verificationResult = await completeRegistrationMutation.mutateAsync({ const verificationResult = await completeRegistrationMutation.mutateAsync({
setup_intent_id: setupIntentId || '', setup_intent_id: setupIntentId || '',
user_data: { user_data: {
@@ -363,6 +365,9 @@ export const PaymentStep: React.FC<PaymentStepProps> = ({
billing_cycle: registrationState.subscription.billingInterval, billing_cycle: registrationState.subscription.billingInterval,
payment_method_id: confirmedPaymentMethodId || pendingPaymentMethodId, payment_method_id: confirmedPaymentMethodId || pendingPaymentMethodId,
coupon_code: registrationState.subscription.couponCode, 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
}, },
}); });

View File

@@ -40,6 +40,7 @@ export type PaymentSetupData = {
paymentMethodId?: string; paymentMethodId?: string;
planId?: string; planId?: string;
threedsCompleted?: boolean; threedsCompleted?: boolean;
trialPeriodDays?: number; // Trial period from coupon (e.g., 90 for PILOT2025)
}; };
export type RegistrationState = { export type RegistrationState = {

View File

@@ -129,13 +129,15 @@ class AuthService:
payment_setup_result: Optional[Dict[str, Any]] = None payment_setup_result: Optional[Dict[str, Any]] = None
) -> Dict[str, Any]: ) -> Dict[str, Any]:
""" """
Complete registration after successful payment verification Complete registration after successful payment verification.
This is called AFTER frontend confirms SetupIntent and handles 3DS
NEW ARCHITECTURE: This calls tenant service to create subscription
AFTER SetupIntent verification. No subscription exists until this point.
Args: 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 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: Returns:
Complete registration result Complete registration result
@@ -144,26 +146,37 @@ class AuthService:
RegistrationError: If registration completion fails RegistrationError: If registration completion fails
""" """
try: 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 if not setup_intent_id:
# This happens when no 3DS was required and subscription was created immediately raise RegistrationError("SetupIntent ID is required for registration completion")
if payment_setup_result and payment_setup_result.get('subscription_id'):
subscription_result = payment_setup_result # Get customer_id and other data from payment_setup_result
elif setup_intent_id: customer_id = ""
# Step 1: Verify SetupIntent and create subscription via tenant service payment_method_id = ""
subscription_result = await self.tenant_client.verify_and_complete_registration( trial_period_days = 0
setup_intent_id,
{ if payment_setup_result:
"email": user_data.email, customer_id = payment_setup_result.get('customer_id') or payment_setup_result.get('payment_customer_id', '')
"full_name": user_data.full_name, payment_method_id = payment_setup_result.get('payment_method_id', '')
"plan_id": user_data.subscription_plan or "professional", trial_period_days = payment_setup_result.get('trial_period_days', 0)
"billing_cycle": user_data.billing_cycle or "monthly",
"coupon_code": user_data.coupon_code # Call tenant service to verify SetupIntent and CREATE subscription
} subscription_result = await self.tenant_client.verify_and_complete_registration(
) setup_intent_id,
else: {
raise RegistrationError("No setup_intent_id or subscription_id available for registration completion") "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 # Use a single database session for both user creation and onboarding progress
# to ensure proper transaction handling and avoid foreign key constraint violations # to ensure proper transaction handling and avoid foreign key constraint violations
@@ -256,37 +269,35 @@ class AuthService:
# Check if SetupIntent requires action (3DS) # Check if SetupIntent requires action (3DS)
if payment_setup_result.get('requires_action', False): 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 # Return SetupIntent for frontend to handle 3DS
# Note: subscription_id is included because for trial subscriptions, # Note: NO subscription exists yet - subscription is created after verification
# the subscription is already created in 'trialing' status
return { return {
'requires_action': True, 'requires_action': True,
'action_type': 'setup_intent_confirmation', 'action_type': 'setup_intent_confirmation',
'client_secret': payment_setup_result.get('client_secret'), 'client_secret': payment_setup_result.get('client_secret'),
'setup_intent_id': payment_setup_result.get('setup_intent_id'), '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'), 'customer_id': payment_setup_result.get('customer_id'),
'payment_customer_id': payment_setup_result.get('payment_customer_id'), 'payment_customer_id': payment_setup_result.get('payment_customer_id'),
'plan_id': payment_setup_result.get('plan_id'), 'plan_id': payment_setup_result.get('plan_id'),
'payment_method_id': payment_setup_result.get('payment_method_id'), 'payment_method_id': payment_setup_result.get('payment_method_id'),
'billing_cycle': payment_setup_result.get('billing_cycle'), 'billing_cycle': payment_setup_result.get('billing_cycle'),
'coupon_info': payment_setup_result.get('coupon_info'), 'trial_period_days': payment_setup_result.get('trial_period_days', 0),
'trial_info': payment_setup_result.get('trial_info'), 'coupon_code': payment_setup_result.get('coupon_code'),
'email': payment_setup_result.get('email'), '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: 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 # Complete registration - create subscription now
# setup_intent_id may be None if no 3DS was required - use subscription_id instead
setup_intent_id = payment_setup_result.get('setup_intent_id') setup_intent_id = payment_setup_result.get('setup_intent_id')
registration_result = await self.complete_registration_after_payment_verification( registration_result = await self.complete_registration_after_payment_verification(
setup_intent_id, setup_intent_id,
user_data, user_data,
payment_setup_result # Pass full result for additional context payment_setup_result
) )
return { return {
@@ -295,8 +306,8 @@ class AuthService:
'subscription_id': registration_result.get('subscription_id'), 'subscription_id': registration_result.get('subscription_id'),
'payment_customer_id': registration_result.get('payment_customer_id'), 'payment_customer_id': registration_result.get('payment_customer_id'),
'status': registration_result.get('status'), 'status': registration_result.get('status'),
'coupon_info': registration_result.get('coupon_info'), 'access_token': registration_result.get('access_token'),
'trial_info': registration_result.get('trial_info'), 'refresh_token': registration_result.get('refresh_token'),
'message': 'Registration completed successfully' 'message': 'Registration completed successfully'
} }

View File

@@ -1,6 +1,13 @@
""" """
Tenant Service API Endpoints for Subscription and Registration 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 import logging
@@ -8,6 +15,7 @@ from typing import Dict, Any
from fastapi import APIRouter, Depends, HTTPException, status, Request, Query from fastapi import APIRouter, Depends, HTTPException, status, Request, Query
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.services.subscription_orchestration_service import SubscriptionOrchestrationService from app.services.subscription_orchestration_service import SubscriptionOrchestrationService
from app.services.coupon_service import CouponService
from app.core.database import get_db from app.core.database import get_db
from app.services.registration_state_service import ( from app.services.registration_state_service import (
registration_state_service, registration_state_service,
@@ -18,17 +26,13 @@ from shared.exceptions.payment_exceptions import (
PaymentServiceError, PaymentServiceError,
SetupIntentError, SetupIntentError,
SubscriptionCreationFailed, SubscriptionCreationFailed,
ThreeDSAuthenticationRequired
) )
from shared.exceptions.registration_exceptions import ( from shared.exceptions.registration_exceptions import (
RegistrationStateError, RegistrationStateError,
InvalidStateTransitionError
) )
# Configure logging
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# Create router
router = APIRouter(prefix="/api/v1/tenants", tags=["tenant"]) router = APIRouter(prefix="/api/v1/tenants", tags=["tenant"])
@@ -46,7 +50,7 @@ async def get_registration_state_service() -> RegistrationStateService:
@router.post("/registration-payment-setup", @router.post("/registration-payment-setup",
response_model=Dict[str, Any], response_model=Dict[str, Any],
summary="Initiate registration payment setup") summary="Start registration payment setup")
async def create_registration_payment_setup( async def create_registration_payment_setup(
user_data: Dict[str, Any], user_data: Dict[str, Any],
request: Request, request: Request,
@@ -54,69 +58,46 @@ async def create_registration_payment_setup(
state_service: RegistrationStateService = Depends(get_registration_state_service) state_service: RegistrationStateService = Depends(get_registration_state_service)
) -> Dict[str, Any]: ) -> Dict[str, Any]:
""" """
Initiate registration payment setup with SetupIntent-first approach Start registration payment setup (SetupIntent-first architecture).
This is the FIRST step in secure registration flow: NEW ARCHITECTURE: Only creates customer + SetupIntent here.
1. Creates payment customer NO subscription is created - subscription is created in verify-and-complete-registration.
2. Attaches payment method
3. Creates SetupIntent for verification Flow:
4. Returns SetupIntent to frontend for 3DS handling 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: Args:
user_data: User registration data with payment info user_data: User registration data with payment info
Returns: Returns:
Payment setup result (may require 3DS) SetupIntent data for frontend confirmation
Raises:
HTTPException: 400 for validation errors, 500 for server errors
""" """
state_id = None
try: try:
print(f"DEBUG_PRINT: Registration payment setup request received for {user_data.get('email')}") logger.info("Registration payment setup started",
logger.critical( extra={"email": user_data.get('email'), "plan_id": user_data.get('plan_id')})
"Registration payment setup request received (CRITICAL)",
extra={
"email": user_data.get('email'),
"plan_id": user_data.get('plan_id')
}
)
# Validate required fields # Validate required fields
if not user_data.get('email'): 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'): 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'): 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 # 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( state_id = await state_service.create_registration_state(
email=user_data['email'], email=user_data['email'],
user_data=user_data user_data=user_data
) )
logger.critical("Registration state created", extra={"state_id": state_id, "email": user_data['email']})
# Initiate payment setup # Create customer + SetupIntent (NO subscription yet!)
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']})
result = await orchestration_service.create_registration_payment_setup( result = await orchestration_service.create_registration_payment_setup(
user_data=user_data, user_data=user_data,
plan_id=user_data.get('plan_id', 'professional'), 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'), billing_interval=user_data.get('billing_cycle', 'monthly'),
coupon_code=user_data.get('coupon_code') 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 # Update state with setup results
# Note: setup_intent_id may not be present if no 3DS was required
await state_service.update_state_context(state_id, { await state_service.update_state_context(state_id, {
'setup_intent_id': result.get('setup_intent_id'), 'setup_intent_id': result.get('setup_intent_id'),
'subscription_id': result.get('subscription_id'),
'customer_id': result.get('customer_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)
await state_service.transition_state(
state_id,
RegistrationState.PAYMENT_VERIFICATION_PENDING
)
logger.critical( logger.info("Registration payment setup completed",
"Registration payment setup flow successful", extra={
extra={ "email": user_data.get('email'),
"email": user_data.get('email'), "setup_intent_id": result.get('setup_intent_id'),
"state_id": state_id "requires_action": result.get('requires_action')
} })
)
return { return {
"success": True, "success": True,
"requires_action": result.get('requires_action', False), "requires_action": result.get('requires_action', True),
"action_type": result.get('action_type'), "action_type": result.get('action_type', 'use_stripe_sdk'),
"client_secret": result.get('client_secret'), "client_secret": result.get('client_secret'),
"setup_intent_id": result.get('setup_intent_id'), "setup_intent_id": result.get('setup_intent_id'),
"customer_id": result.get('customer_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'), "plan_id": result.get('plan_id'),
"payment_method_id": result.get('payment_method_id'), "payment_method_id": result.get('payment_method_id'),
"subscription_id": result.get('subscription_id'), "trial_period_days": result.get('trial_period_days', 0),
"billing_cycle": result.get('billing_cycle'), "billing_cycle": result.get('billing_interval'),
"email": result.get('email'), "email": result.get('email'),
"state_id": state_id, "state_id": state_id,
"message": result.get('message') or "Payment setup completed successfully." "message": result.get('message', 'Payment verification required')
}
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."
} }
except PaymentServiceError as e: except PaymentServiceError as e:
logger.error(f"Payment service error in registration setup: {str(e)}, email: {user_data.get('email')}", logger.error(f"Payment setup failed: {str(e)}", extra={"email": user_data.get('email')}, exc_info=True)
extra={"email": user_data.get('email')}, raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"Payment setup failed: {str(e)}") from e
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: except RegistrationStateError as e:
logger.error(f"Registration state error in payment setup: {str(e)}, email: {user_data.get('email')}", logger.error(f"Registration state error: {str(e)}", extra={"email": user_data.get('email')}, exc_info=True)
extra={"email": user_data.get('email')}, raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"Registration state error: {str(e)}") from e
exc_info=True) except HTTPException:
raise HTTPException( raise
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Registration state error: {str(e)}"
) from e
except Exception as e: except Exception as e:
logger.error(f"Unexpected error in registration payment setup: {str(e)}, email: {user_data.get('email')}", logger.error(f"Unexpected error: {str(e)}", extra={"email": user_data.get('email')}, exc_info=True)
extra={"email": user_data.get('email')}, raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"Registration failed: {str(e)}") from e
exc_info=True)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Registration payment setup failed: {str(e)}"
) from e
@router.post("/verify-and-complete-registration", @router.post("/verify-and-complete-registration",
@@ -234,173 +162,102 @@ async def create_registration_payment_setup(
async def verify_and_complete_registration( async def verify_and_complete_registration(
verification_data: Dict[str, Any], verification_data: Dict[str, Any],
request: Request, request: Request,
db: AsyncSession = Depends(get_db),
orchestration_service: SubscriptionOrchestrationService = Depends(get_subscription_orchestration_service), orchestration_service: SubscriptionOrchestrationService = Depends(get_subscription_orchestration_service),
state_service: RegistrationStateService = Depends(get_registration_state_service) state_service: RegistrationStateService = Depends(get_registration_state_service)
) -> Dict[str, Any]: ) -> Dict[str, Any]:
""" """
Complete registration after frontend confirms SetupIntent (3DS handled) Complete registration after frontend confirms SetupIntent.
This is the SECOND step in registration architecture: NEW ARCHITECTURE: Creates subscription HERE (not in payment-setup).
1. Verifies SetupIntent status This is the ONLY place subscriptions are created during registration.
2. Creates subscription with verified payment method
3. Updates registration state Flow:
1. Verify SetupIntent status is 'succeeded'
2. Create subscription with verified payment method
3. Update registration state
Args: Args:
verification_data: SetupIntent verification data verification_data: SetupIntent verification data with user_data
Returns: Returns:
Complete registration result with subscription Subscription creation result
Raises:
HTTPException: 400 for validation errors, 500 for server errors
""" """
setup_intent_id = None
user_data = {}
state_id = None
try: try:
# Validate required fields # Validate required fields
if not verification_data.get('setup_intent_id'): if not verification_data.get('setup_intent_id'):
raise HTTPException( raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="SetupIntent ID is required")
status_code=status.HTTP_400_BAD_REQUEST,
detail="SetupIntent ID is required"
)
if not verification_data.get('user_data'): if not verification_data.get('user_data'):
raise HTTPException( raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="User data is required")
status_code=status.HTTP_400_BAD_REQUEST,
detail="User data is required"
)
setup_intent_id = verification_data['setup_intent_id'] setup_intent_id = verification_data['setup_intent_id']
user_data = verification_data['user_data'] user_data = verification_data['user_data']
state_id = verification_data.get('state_id') state_id = verification_data.get('state_id')
logger.info( logger.info("Completing registration after verification",
"Completing registration after SetupIntent verification", extra={"email": user_data.get('email'), "setup_intent_id": setup_intent_id})
extra={
"email": user_data.get('email'),
"setup_intent_id": setup_intent_id
}
)
# Get registration state if provided # Calculate trial period from coupon if provided in the completion call
if state_id: trial_period_days = 0
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"
)
# 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)
coupon_code = user_data.get('coupon_code') 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:
if coupon_code and trial_period_days == 0: logger.info("Validating coupon in completion call",
try: extra={"coupon_code": coupon_code, "email": user_data.get('email')})
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
# Check if a subscription already exists for this customer # Create coupon service to validate coupon
existing_subscriptions = await orchestration_service.get_subscriptions_by_customer_id(actual_customer_id) coupon_service = CouponService(db)
success, discount_applied, error = await coupon_service.redeem_coupon(
if existing_subscriptions: coupon_code,
# If we already have a trial subscription, update it instead of creating a new one None, # No tenant_id yet
existing_subscription = existing_subscriptions[0] # Get the first subscription base_trial_days=0
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 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')
)
# Update registration state
if state_id: if state_id:
try: try:
await state_service.update_state_context(state_id, { await state_service.update_state_context(state_id, {
'subscription_id': result['subscription_id'], 'subscription_id': result['subscription_id'],
'status': result['status'] 'status': result['status']
}) })
await state_service.transition_state(state_id, RegistrationState.SUBSCRIPTION_CREATED)
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']
}
)
except Exception as e: except Exception as e:
logger.error("Failed to update registration state after subscription creation", logger.warning(f"Failed to update registration state: {e}", extra={"state_id": state_id})
extra={
"error": str(e),
"state_id": state_id
},
exc_info=True)
logger.info("Registration completed successfully after 3DS verification", logger.info("Registration subscription created successfully",
extra={ extra={
"email": user_data.get('email'), "email": user_data.get('email'),
"subscription_id": result['subscription_id'] "subscription_id": result['subscription_id'],
"status": result['status']
}) })
return { return {
@@ -409,83 +266,49 @@ async def verify_and_complete_registration(
"customer_id": result['customer_id'], "customer_id": result['customer_id'],
"payment_customer_id": result.get('payment_customer_id', result['customer_id']), "payment_customer_id": result.get('payment_customer_id', result['customer_id']),
"status": result['status'], "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'), "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'), "current_period_end": result.get('current_period_end'),
"state_id": state_id, "state_id": state_id,
"message": "Registration completed successfully after 3DS verification" "message": "Subscription created successfully"
} }
except SetupIntentError as e: except SetupIntentError as e:
logger.error("SetupIntent verification failed", logger.error(f"SetupIntent verification failed: {e}",
extra={ extra={"setup_intent_id": setup_intent_id, "email": user_data.get('email')},
"error": str(e),
"setup_intent_id": setup_intent_id,
"email": user_data.get('email')
},
exc_info=True) exc_info=True)
# Mark registration as failed if state tracking
if state_id: if state_id:
try: try:
await state_service.mark_registration_failed( await state_service.mark_registration_failed(state_id, f"Verification failed: {e}")
state_id,
f"SetupIntent verification failed: {str(e)}"
)
except Exception: except Exception:
pass # Don't fail main operation for state tracking failure pass
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=f"Payment verification failed: {e}") from e
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"SetupIntent verification failed: {str(e)}"
) from e
except SubscriptionCreationFailed as e: except SubscriptionCreationFailed as e:
logger.error("Subscription creation failed after verification", logger.error(f"Subscription creation failed: {e}",
extra={ extra={"setup_intent_id": setup_intent_id, "email": user_data.get('email')},
"error": str(e),
"setup_intent_id": setup_intent_id,
"email": user_data.get('email')
},
exc_info=True) exc_info=True)
# Mark registration as failed if state tracking
if state_id: if state_id:
try: try:
await state_service.mark_registration_failed( await state_service.mark_registration_failed(state_id, f"Subscription failed: {e}")
state_id,
f"Subscription creation failed: {str(e)}"
)
except Exception: except Exception:
pass # Don't fail main operation for state tracking failure pass
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"Subscription creation failed: {e}") from e
except HTTPException:
raise
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Subscription creation failed: {str(e)}"
) from e
except Exception as e: except Exception as e:
logger.error("Unexpected error in registration completion", logger.error(f"Unexpected error: {e}",
extra={ extra={"setup_intent_id": setup_intent_id, "email": user_data.get('email')},
"error": str(e),
"setup_intent_id": setup_intent_id,
"email": user_data.get('email')
},
exc_info=True) exc_info=True)
# Mark registration as failed if state tracking
if state_id: if state_id:
try: try:
await state_service.mark_registration_failed( await state_service.mark_registration_failed(state_id, f"Registration failed: {e}")
state_id,
f"Registration completion failed: {str(e)}"
)
except Exception: except Exception:
pass # Don't fail main operation for state tracking failure pass
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"Registration failed: {e}") from e
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Registration completion failed: {str(e)}"
) from e
@router.get("/registration-state/{state_id}", @router.get("/registration-state/{state_id}",

View File

@@ -12,7 +12,9 @@ from shared.exceptions.payment_exceptions import (
SubscriptionCreationFailed, SubscriptionCreationFailed,
SetupIntentError, SetupIntentError,
PaymentServiceError, PaymentServiceError,
SubscriptionUpdateFailed SubscriptionUpdateFailed,
PaymentMethodError,
CustomerUpdateFailed
) )
from shared.utils.retry import retry_with_backoff from shared.utils.retry import retry_with_backoff
@@ -146,8 +148,7 @@ class PaymentService:
metadata: Optional[Dict[str, Any]] = None metadata: Optional[Dict[str, Any]] = None
) -> Dict[str, Any]: ) -> Dict[str, Any]:
""" """
Atomic: Create SetupIntent for payment method verification Create SetupIntent for payment verification.
This is the FIRST step in secure registration flow
Args: Args:
customer_id: Stripe customer ID customer_id: Stripe customer ID
@@ -155,17 +156,16 @@ class PaymentService:
metadata: Additional metadata for tracking metadata: Additional metadata for tracking
Returns: Returns:
SetupIntent result with verification requirements SetupIntent result for frontend confirmation
Raises: Raises:
SetupIntentError: If SetupIntent creation fails SetupIntentError: If SetupIntent creation fails
""" """
try: try:
# Add registration-specific metadata
full_metadata = metadata or {} full_metadata = metadata or {}
full_metadata.update({ full_metadata.update({
'service': 'tenant', 'service': 'tenant',
'operation': 'registration_payment_verification', 'operation': 'verification_setup_intent',
'timestamp': datetime.now().isoformat() 'timestamp': datetime.now().isoformat()
}) })
@@ -177,38 +177,305 @@ class PaymentService:
exceptions=(SetupIntentError,) exceptions=(SetupIntentError,)
) )
logger.info("SetupIntent created for payment verification", logger.info("SetupIntent created for verification",
setup_intent_id=result['setup_intent_id'], setup_intent_id=result['setup_intent_id'],
customer_id=customer_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 return result
except SetupIntentError as e: 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) exc_info=True)
raise raise
except Exception as e: 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) exc_info=True)
raise SetupIntentError(f"Unexpected SetupIntent error: {str(e)}") from e raise SetupIntentError(f"Unexpected SetupIntent error: {str(e)}") from e
async def verify_setup_intent_status( # 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(
self, self,
setup_intent_id: str setup_intent_id: str
) -> Dict[str, Any]: ) -> Dict[str, Any]:
""" """
Atomic: Verify SetupIntent status after frontend confirmation Verify SetupIntent status after frontend confirmation.
Args: Args:
setup_intent_id: SetupIntent ID to verify setup_intent_id: SetupIntent ID to verify
Returns: Returns:
SetupIntent verification result SetupIntent verification result with 'verified' boolean
Raises: Raises:
SetupIntentError: If verification fails SetupIntentError: If retrieval fails
""" """
try: try:
result = await retry_with_backoff( result = await retry_with_backoff(
@@ -222,20 +489,16 @@ class PaymentService:
status=result['status'], status=result['status'],
verified=result.get('verified', False)) 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 return result
except SetupIntentError as e: 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) exc_info=True)
raise raise
except Exception as e: 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) exc_info=True)
raise SetupIntentError(f"Unexpected verification error: {str(e)}") from e raise SetupIntentError(f"Unexpected verification error: {str(e)}") from e
@@ -884,7 +1147,7 @@ class PaymentService:
exc_info=True) exc_info=True)
raise PaymentServiceError(f"Subscription completion failed: {str(e)}") from e raise PaymentServiceError(f"Subscription completion failed: {str(e)}") from e
async def verify_setup_intent( async def verify_setup_intent_status(
self, self,
setup_intent_id: str setup_intent_id: str
) -> Dict[str, Any]: ) -> Dict[str, Any]:
@@ -956,6 +1219,86 @@ class PaymentService:
exc_info=True) exc_info=True)
raise SubscriptionUpdateFailed(f"Failed to update payment method: {str(e)}") from e 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 # Singleton instance for dependency injection
payment_service = PaymentService() payment_service = PaymentService()

View File

@@ -5,7 +5,7 @@ This service orchestrates complex workflows involving multiple services
""" """
import structlog import structlog
from typing import Dict, Any, Optional from typing import Dict, Any, Optional, List
from datetime import datetime, timezone from datetime import datetime, timezone
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import Session 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.payment_service import PaymentService
from app.services.coupon_service import CouponService from app.services.coupon_service import CouponService
from app.services.tenant_service import EnhancedTenantService from app.services.tenant_service import EnhancedTenantService
from app.models.tenants import Subscription
from app.core.config import settings from app.core.config import settings
from shared.database.exceptions import DatabaseError, ValidationError from shared.database.exceptions import DatabaseError, ValidationError
from shared.database.base import create_database_manager from shared.database.base import create_database_manager
@@ -1619,55 +1620,58 @@ class SubscriptionOrchestrationService:
coupon_code: Optional[str] = None coupon_code: Optional[str] = None
) -> Dict[str, Any]: ) -> Dict[str, Any]:
""" """
Create payment customer and SetupIntent for registration (pre-user-creation) Create payment customer and SetupIntent for registration.
This method supports the secure architecture where users are only created NEW ARCHITECTURE: Only creates customer + SetupIntent here.
after payment verification. It creates a payment customer and SetupIntent Subscription is created AFTER SetupIntent verification completes.
without requiring a user_id.
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: 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 plan_id: Subscription plan ID
payment_method_id: Payment method ID from frontend payment_method_id: Payment method ID from frontend
billing_interval: Billing interval (monthly/yearly) billing_interval: Billing interval (monthly/yearly)
coupon_code: Optional coupon code coupon_code: Optional coupon code
Returns: Returns:
Dictionary with payment setup results including SetupIntent if required Dictionary with SetupIntent data for frontend
Raises: Raises:
Exception: If payment setup fails Exception: If payment setup fails
""" """
try: try:
logger.info("Starting registration payment setup (pre-user-creation)", logger.info("Starting registration payment setup",
email=user_data.get('email'), email=user_data.get('email'),
plan_id=plan_id) plan_id=plan_id)
# Step 1: Create payment customer (without user_id) # Step 1: Create payment customer
logger.info("Creating payment customer for registration",
email=user_data.get('email'))
# Create customer without user_id metadata
email = user_data.get('email') email = user_data.get('email')
name = user_data.get('full_name') name = user_data.get('full_name')
metadata = { 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() 'timestamp': datetime.now(timezone.utc).isoformat()
} }
customer = await self.payment_service.create_customer(email, name, metadata) 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, customer_id=customer.id,
email=user_data.get('email')) email=email)
# Step 2: Handle coupon logic (if provided) # Step 2: Handle coupon logic (if provided)
trial_period_days = 0 trial_period_days = 0
coupon_discount = None
if coupon_code: if coupon_code:
logger.info("Validating and redeeming coupon code for registration", logger.info("Validating coupon for registration",
coupon_code=coupon_code, coupon_code=coupon_code,
email=user_data.get('email')) email=email)
coupon_service = CouponService(self.db_session) coupon_service = CouponService(self.db_session)
success, discount_applied, error = await coupon_service.redeem_coupon( success, discount_applied, error = await coupon_service.redeem_coupon(
@@ -1677,82 +1681,55 @@ class SubscriptionOrchestrationService:
) )
if success and discount_applied: if success and discount_applied:
coupon_discount = discount_applied
trial_period_days = discount_applied.get("total_trial_days", 0) 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, coupon_code=coupon_code,
trial_period_days=trial_period_days) trial_period_days=trial_period_days)
else: 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, coupon_code=coupon_code,
error=error) error=error)
# Step 3: Create subscription/SetupIntent # Step 3: Create SetupIntent (NO subscription yet!)
logger.info("Creating subscription/SetupIntent for registration", logger.info("Creating SetupIntent for registration",
customer_id=customer.id, customer_id=customer.id,
plan_id=plan_id,
payment_method_id=payment_method_id) payment_method_id=payment_method_id)
# Get the Stripe price ID for this plan setup_result = await self.payment_service.create_setup_intent_for_registration(
price_id = self.payment_service._get_stripe_price_id(plan_id, billing_interval)
subscription_result = await self.payment_service.create_subscription_with_verified_payment(
customer.id, customer.id,
price_id,
payment_method_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) logger.info("SetupIntent created for registration",
if isinstance(subscription_result, dict) and subscription_result.get('requires_action'): setup_intent_id=setup_result.get('setup_intent_id'),
logger.info("Registration payment setup requires SetupIntent confirmation", requires_action=setup_result.get('requires_action'),
customer_id=customer.id, status=setup_result.get('status'))
action_type=subscription_result.get('action_type'),
setup_intent_id=subscription_result.get('setup_intent_id'),
subscription_id=subscription_result.get('subscription_id'))
# Return the SetupIntent data for frontend to handle 3DS # Return result for frontend
# Note: subscription_id is included because for trial subscriptions, # Frontend will call complete_registration_subscription() after 3DS
# the subscription is already created in 'trialing' status even though return {
# the SetupIntent requires 3DS verification for future payments "requires_action": setup_result.get('requires_action', True),
return { "action_type": "use_stripe_sdk",
"requires_action": True, "client_secret": setup_result.get('client_secret'),
"action_type": subscription_result.get('action_type') or 'use_stripe_sdk', "setup_intent_id": setup_result.get('setup_intent_id'),
"client_secret": subscription_result.get('client_secret'), "customer_id": customer.id,
"setup_intent_id": subscription_result.get('setup_intent_id'), "payment_customer_id": customer.id,
"subscription_id": subscription_result.get('subscription_id'), "plan_id": plan_id,
"customer_id": customer.id, "payment_method_id": payment_method_id,
"payment_customer_id": customer.id, "trial_period_days": trial_period_days,
"plan_id": plan_id, "billing_interval": billing_interval,
"payment_method_id": payment_method_id, "coupon_code": coupon_code,
"trial_period_days": trial_period_days, "email": email,
"billing_interval": billing_interval, "full_name": name,
"coupon_applied": coupon_code is not None, "message": "Payment verification required" if setup_result.get('requires_action') else "Payment verified"
"email": user_data.get('email'), }
"full_name": user_data.get('full_name'),
"message": subscription_result.get('message') or "Payment verification required before account creation"
}
else:
# No 3DS required - subscription created successfully
logger.info("Registration payment setup completed without 3DS",
customer_id=customer.id,
subscription_id=subscription_result.get('subscription_id'))
return {
"requires_action": False,
"subscription_id": subscription_result.get('subscription_id'),
"customer_id": customer.id,
"payment_customer_id": customer.id,
"plan_id": plan_id,
"payment_method_id": payment_method_id,
"trial_period_days": trial_period_days,
"billing_interval": billing_interval,
"coupon_applied": coupon_code is not None,
"email": user_data.get('email'),
"full_name": user_data.get('full_name'),
"message": "Payment setup completed successfully"
}
except Exception as e: except Exception as e:
logger.error("Registration payment setup failed", logger.error("Registration payment setup failed",
@@ -1766,11 +1743,7 @@ class SubscriptionOrchestrationService:
setup_intent_id: str setup_intent_id: str
) -> Dict[str, Any]: ) -> Dict[str, Any]:
""" """
Verify SetupIntent status for registration completion Verify SetupIntent status for registration completion.
This method checks if a SetupIntent has been successfully confirmed
(either automatically or via 3DS authentication) before proceeding
with user creation.
Args: Args:
setup_intent_id: SetupIntent ID to verify setup_intent_id: SetupIntent ID to verify
@@ -1782,25 +1755,131 @@ class SubscriptionOrchestrationService:
Exception: If verification fails Exception: If verification fails
""" """
try: try:
logger.info("Verifying SetupIntent for registration completion", logger.info("Verifying SetupIntent for registration",
setup_intent_id=setup_intent_id) setup_intent_id=setup_intent_id)
# Use payment service to verify SetupIntent
verification_result = await self.payment_service.verify_setup_intent(setup_intent_id) 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, setup_intent_id=setup_intent_id,
status=verification_result.get('status')) status=verification_result.get('status'),
verified=verification_result.get('verified'))
return verification_result return verification_result
except Exception as e: except Exception as e:
logger.error("SetupIntent verification failed for registration", logger.error("SetupIntent verification failed",
setup_intent_id=setup_intent_id, setup_intent_id=setup_intent_id,
error=str(e), error=str(e),
exc_info=True) exc_info=True)
raise 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( async def validate_plan_upgrade(
self, self,
tenant_id: str, tenant_id: str,
@@ -1878,10 +1957,14 @@ class SubscriptionOrchestrationService:
trial_period_days: Optional[int] = None trial_period_days: Optional[int] = None
) -> Dict[str, Any]: ) -> 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 This is used when we already have a trial subscription (created during registration)
attach the verified payment method to it. 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: Args:
subscription_id: Stripe subscription ID subscription_id: Stripe subscription ID
@@ -1893,10 +1976,11 @@ class SubscriptionOrchestrationService:
Dictionary with updated subscription details Dictionary with updated subscription details
""" """
try: try:
logger.info("Updating existing subscription with verified payment method", logger.info("Updating existing trial subscription with verified payment method",
subscription_id=subscription_id, subscription_id=subscription_id,
customer_id=customer_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 # First, verify the subscription exists and get its current status
existing_subscription = await self.subscription_service.get_subscription_by_provider_id(subscription_id) existing_subscription = await self.subscription_service.get_subscription_by_provider_id(subscription_id)
@@ -1904,19 +1988,46 @@ class SubscriptionOrchestrationService:
if not existing_subscription: if not existing_subscription:
raise SubscriptionNotFound(f"Subscription {subscription_id} not found") 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( stripe_subscription = await self.payment_service.update_subscription_payment_method(
subscription_id, subscription_id,
payment_method_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 # Update our local subscription record
await self.subscription_service.update_subscription_status( await self.subscription_service.update_subscription_status(
existing_subscription.tenant_id, existing_subscription.tenant_id,
stripe_subscription.status, stripe_subscription.status,
{ {
'current_period_start': datetime.fromtimestamp(stripe_subscription.current_period_start), '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': { 'verification': {
'verified': True, 'verified': True,
'customer_id': customer_id, '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: 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, subscription_id=subscription_id,
customer_id=customer_id, customer_id=customer_id,
error=str(e), error=str(e),
exc_info=True) exc_info=True)
raise SubscriptionUpdateFailed(f"Failed to update subscription: {str(e)}") raise SubscriptionUpdateFailed(f"Failed to update trial subscription: {str(e)}")

View File

@@ -15,7 +15,9 @@ from shared.exceptions.payment_exceptions import (
PaymentVerificationError, PaymentVerificationError,
SubscriptionCreationFailed, SubscriptionCreationFailed,
SetupIntentError, SetupIntentError,
SubscriptionUpdateFailed SubscriptionUpdateFailed,
PaymentMethodError,
CustomerUpdateFailed
) )
# Configure logging # Configure logging
@@ -42,8 +44,16 @@ class StripeClient(PaymentProvider):
metadata: Optional[Dict[str, Any]] = None metadata: Optional[Dict[str, Any]] = None
) -> Dict[str, Any]: ) -> Dict[str, Any]:
""" """
Atomic: Create SetupIntent for payment method verification Create standalone SetupIntent for payment verification during registration.
This is the FIRST step in secure registration flow
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: Args:
customer_id: Stripe customer ID customer_id: Stripe customer ID
@@ -51,29 +61,58 @@ class StripeClient(PaymentProvider):
metadata: Additional metadata for tracking metadata: Additional metadata for tracking
Returns: Returns:
SetupIntent result with verification requirements SetupIntent result for frontend confirmation
Raises: Raises:
SetupIntentError: If SetupIntent creation fails SetupIntentError: If SetupIntent creation fails
""" """
try: 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 = { setup_intent_params = {
'customer': customer_id, 'customer': customer_id,
'payment_method': payment_method_id, 'payment_method': payment_method_id,
'usage': 'off_session', 'usage': 'off_session', # For future recurring payments
'confirm': False, # Frontend must confirm to handle 3DS 'confirm': True, # Confirm immediately - this triggers 3DS check
'idempotency_key': f"setup_intent_{uuid.uuid4()}", 'idempotency_key': f"setup_intent_reg_{uuid.uuid4()}",
'metadata': metadata or { 'metadata': metadata or {
'purpose': 'registration_payment_verification', '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) setup_intent = stripe.SetupIntent.create(**setup_intent_params)
logger.info( logger.info(
"SetupIntent created for payment verification", "SetupIntent created for verification",
extra={ extra={
"setup_intent_id": setup_intent.id, "setup_intent_id": setup_intent.id,
"status": setup_intent.status, "status": setup_intent.status,
@@ -82,24 +121,23 @@ class StripeClient(PaymentProvider):
} }
) )
# Always return SetupIntent for frontend confirmation # Check if 3DS is required
# Frontend will handle 3DS if required requires_action = setup_intent.status in ['requires_action', 'requires_confirmation']
# Note: With confirm=False, the SetupIntent will have status 'requires_confirmation'
# The actual 3DS requirement is only determined after frontend confirmation
return { return {
'setup_intent_id': setup_intent.id, 'setup_intent_id': setup_intent.id,
'client_secret': setup_intent.client_secret, 'client_secret': setup_intent.client_secret,
'status': setup_intent.status, 'status': setup_intent.status,
'requires_action': True, # Always require frontend confirmation for 3DS support 'requires_action': requires_action,
'customer_id': customer_id, 'customer_id': customer_id,
'payment_method_id': payment_method_id, 'payment_method_id': payment_method_id,
'created': setup_intent.created, 'created': setup_intent.created,
'metadata': setup_intent.metadata 'metadata': dict(setup_intent.metadata) if setup_intent.metadata else {}
} }
except stripe.error.StripeError as e: except stripe.error.StripeError as e:
logger.error( logger.error(
"Stripe SetupIntent creation failed", "SetupIntent creation for verification failed",
extra={ extra={
"error": str(e), "error": str(e),
"error_type": type(e).__name__, "error_type": type(e).__name__,
@@ -111,7 +149,7 @@ class StripeClient(PaymentProvider):
raise SetupIntentError(f"SetupIntent creation failed: {str(e)}") from e raise SetupIntentError(f"SetupIntent creation failed: {str(e)}") from e
except Exception as e: except Exception as e:
logger.error( logger.error(
"Unexpected error creating SetupIntent", "Unexpected error creating SetupIntent for verification",
extra={ extra={
"error": str(e), "error": str(e),
"customer_id": customer_id, "customer_id": customer_id,
@@ -121,6 +159,225 @@ class StripeClient(PaymentProvider):
) )
raise SetupIntentError(f"Unexpected SetupIntent error: {str(e)}") from e 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( async def verify_setup_intent_status(
self, self,
setup_intent_id: str setup_intent_id: str
@@ -159,7 +416,8 @@ class StripeClient(PaymentProvider):
'payment_method_id': setup_intent.payment_method, 'payment_method_id': setup_intent.payment_method,
'verified': True, 'verified': True,
'requires_action': False, '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': elif setup_intent.status == 'requires_action':
return { return {
@@ -1347,6 +1605,102 @@ class StripeClient(PaymentProvider):
) )
raise SubscriptionUpdateFailed(f"Stripe API failed: {str(e)}") from e 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 # Singleton instance for dependency injection
stripe_client = StripeClient() stripe_client = StripeClient()

View File

@@ -68,3 +68,13 @@ class PaymentServiceError(PaymentException):
"""General payment service error""" """General payment service error"""
def __init__(self, message: str = "Payment service error"): def __init__(self, message: str = "Payment service error"):
super().__init__(message) 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)