Add subcription feature 4
This commit is contained in:
@@ -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.
|
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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 = {
|
||||||
|
|||||||
@@ -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'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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}",
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -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)}")
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -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)
|
||||||
Reference in New Issue
Block a user