Add subcription feature

This commit is contained in:
Urtzi Alfaro
2026-01-13 22:22:38 +01:00
parent b931a5c45e
commit 6ddf608d37
61 changed files with 7915 additions and 1238 deletions

846
REARCHITECTURE_PROPOSAL.md Normal file
View File

@@ -0,0 +1,846 @@
# User Registration & Subscription Architecture Rearchitecture Proposal
## Executive Summary
This proposal outlines a comprehensive rearchitecture of the user registration, payment processing, and subscription management flow to address the current limitations and implement the requested multi-phase registration process.
## Current Architecture Analysis
### Current Flow Limitations
1. **Monolithic Registration Process**: The current flow combines user creation, payment processing, and subscription creation in a single step
2. **Tenant-Subscription Coupling**: Subscriptions are created and immediately linked to tenants during registration
3. **Payment Processing Timing**: Payment is processed before user creation is complete
4. **Onboarding Complexity**: The onboarding flow assumes immediate tenant creation with subscription
### Key Components Analysis
#### Frontend Components
- `RegisterForm.tsx`: Multi-step form handling basic info, subscription selection, and payment
- `PaymentForm.tsx`: Stripe payment processing component
- `RegisterTenantStep.tsx`: Tenant creation during onboarding
#### Backend Services
- **Auth Service**: User creation, authentication, and onboarding progress tracking
- **Tenant Service**: Tenant creation, subscription management, and payment processing
- **Shared Clients**: Inter-service communication between auth and tenant services
#### Current Data Flow
```mermaid
graph TD
A[Frontend RegisterForm] -->|User Data + Payment| B[Auth Service Register]
B -->|Create User| C[User Created]
B -->|Call Tenant Service| D[Tenant Service Payment Customer]
D -->|Create Payment Customer| E[Payment Customer Created]
C -->|Return Tokens| F[User Authenticated]
F -->|Onboarding| G[RegisterTenantStep]
G -->|Create Tenant + Subscription| H[Tenant Service Create Tenant]
H -->|Create Subscription| I[Subscription Created]
```
## Proposed Architecture
### New Multi-Phase Registration Flow
```mermaid
graph TD
subgraph Frontend
A1[Basic Info Form] -->|Email + Password| A2[Subscription Selection]
A2 -->|Plan + Billing Cycle| A3[Payment Form]
A3 -->|Payment Method| A4[Process Payment]
end
subgraph Backend Services
A4 -->|User Data + Payment| B1[Auth Service Register]
B1 -->|Create User| B2[User Created with Payment ID]
B2 -->|Call Tenant Service| B3[Tenant Service Create Subscription]
B3 -->|Create Subscription| B4[Subscription Created]
B4 -->|Return Subscription ID| B2
B2 -->|Return Auth Tokens| A4
end
subgraph Onboarding
A4 -->|Success| C1[Onboarding Flow]
C1 -->|Tenant Creation| C2[RegisterTenantStep]
C2 -->|Tenant Data| C3[Tenant Service Create Tenant]
C3 -->|Link Subscription| C4[Link Subscription to Tenant]
C4 -->|Complete| C5[Onboarding Complete]
end
```
### Detailed Component Changes
#### 1. Frontend Changes
**RegisterForm.tsx Modifications:**
- **Phase 1**: Collect only email and password (basic info)
- **Phase 2**: Plan selection with billing cycle options
- **Phase 3**: Payment form with address and card details
- **Payment Processing**: Call new backend endpoint with complete registration data
**New Payment Flow:**
```typescript
// Current: handleRegistrationSubmit calls authService.register directly
// New: handleRegistrationSubmit calls new registration endpoint
const handleRegistrationSubmit = async (paymentMethodId?: string) => {
try {
const registrationData = {
email: formData.email,
password: formData.password,
full_name: formData.full_name,
subscription_plan: selectedPlan,
billing_cycle: billingCycle,
payment_method_id: paymentMethodId,
coupon_code: isPilot ? couponCode : undefined,
// Address and billing info
address: billingAddress,
postal_code: billingPostalCode,
city: billingCity,
country: billingCountry
};
// Call new registration endpoint
const response = await authService.registerWithSubscription(registrationData);
// Handle success and redirect to onboarding
onSuccess?.();
} catch (err) {
// Handle errors
}
};
```
#### 2. Auth Service Changes
**New Registration Endpoint:**
```python
@router.post("/api/v1/auth/register-with-subscription")
async def register_with_subscription(
user_data: UserRegistrationWithSubscription,
auth_service: EnhancedAuthService = Depends(get_auth_service)
):
"""Register user and create subscription in one call"""
# Step 1: Create user
user = await auth_service.register_user(user_data)
# Step 2: Create payment customer via tenant service
payment_result = await auth_service.create_payment_customer_via_tenant_service(
user_data,
user_data.payment_method_id
)
# Step 3: Create subscription via tenant service
subscription_result = await auth_service.create_subscription_via_tenant_service(
user.id,
user_data.subscription_plan,
user_data.payment_method_id,
user_data.billing_cycle,
user_data.coupon_code
)
# Step 4: Store subscription ID in user's onboarding progress
await auth_service.save_subscription_to_onboarding_progress(
user.id,
subscription_result.subscription_id,
user_data
)
return {
**user,
subscription_id: subscription_result.subscription_id
}
```
**Enhanced Auth Service Methods:**
```python
class EnhancedAuthService:
async def create_subscription_via_tenant_service(
self,
user_id: str,
plan_id: str,
payment_method_id: str,
billing_cycle: str,
coupon_code: Optional[str] = None
) -> Dict[str, Any]:
"""Create subscription via tenant service during registration"""
try:
from shared.clients.tenant_client import TenantServiceClient
from app.core.config import settings
tenant_client = TenantServiceClient(settings)
# Prepare user data for tenant service
user_data = await self.get_user_data_for_tenant_service(user_id)
# Call tenant service to create subscription
result = await tenant_client.create_subscription_for_registration(
user_data=user_data,
plan_id=plan_id,
payment_method_id=payment_method_id,
billing_cycle=billing_cycle,
coupon_code=coupon_code
)
return result
except Exception as e:
logger.error("Failed to create subscription via tenant service",
user_id=user_id, error=str(e))
raise
async def save_subscription_to_onboarding_progress(
self,
user_id: str,
subscription_id: str,
registration_data: Dict[str, Any]
):
"""Store subscription info in onboarding progress for later tenant linking"""
try:
# Get or create onboarding progress
progress = await self.onboarding_repo.get_user_progress(user_id)
if not progress:
progress = await self.onboarding_repo.create_user_progress(user_id)
# Store subscription data in user_registered step
step_data = {
"subscription_id": subscription_id,
"subscription_plan": registration_data.subscription_plan,
"billing_cycle": registration_data.billing_cycle,
"coupon_code": registration_data.coupon_code,
"payment_method_id": registration_data.payment_method_id,
"payment_customer_id": registration_data.payment_customer_id,
"created_at": datetime.now(timezone.utc).isoformat(),
"status": "pending_tenant_linking"
}
await self.onboarding_repo.upsert_user_step(
user_id=user_id,
step_name="user_registered",
completed=True,
step_data=step_data
)
logger.info("Subscription data saved to onboarding progress",
user_id=user_id,
subscription_id=subscription_id)
except Exception as e:
logger.error("Failed to save subscription to onboarding progress",
user_id=user_id, error=str(e))
raise
```
#### 3. Tenant Service Changes
**New Subscription Creation Endpoint:**
```python
@router.post("/api/v1/subscriptions/create-for-registration")
async def create_subscription_for_registration(
user_data: Dict[str, Any],
plan_id: str = Query(...),
payment_method_id: str = Query(...),
billing_cycle: str = Query("monthly"),
coupon_code: Optional[str] = Query(None),
payment_service: PaymentService = Depends(get_payment_service),
db: AsyncSession = Depends(get_db)
):
"""
Create subscription during user registration (before tenant creation)
This endpoint creates a subscription that is not yet linked to any tenant.
The subscription will be linked to a tenant during the onboarding flow.
"""
try:
# Use orchestration service for complete workflow
orchestration_service = SubscriptionOrchestrationService(db)
# Create subscription without tenant_id (tenant-independent subscription)
result = await orchestration_service.create_tenant_independent_subscription(
user_data,
plan_id,
payment_method_id,
billing_cycle,
coupon_code
)
logger.info("Tenant-independent subscription created for registration",
user_id=user_data.get('user_id'),
subscription_id=result["subscription_id"])
return {
"success": True,
"subscription_id": result["subscription_id"],
"customer_id": result["customer_id"],
"status": result["status"],
"plan": result["plan"],
"billing_cycle": result["billing_cycle"]
}
except Exception as e:
logger.error("Failed to create tenant-independent subscription",
error=str(e),
user_id=user_data.get('user_id'))
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to create subscription"
)
```
**Enhanced Subscription Orchestration Service:**
```python
class SubscriptionOrchestrationService:
async def create_tenant_independent_subscription(
self,
user_data: Dict[str, Any],
plan_id: str,
payment_method_id: str,
billing_cycle: str = "monthly",
coupon_code: Optional[str] = None
) -> Dict[str, Any]:
"""
Create a subscription that is not linked to any tenant yet
This subscription will be linked to a tenant during onboarding
when the user creates their bakery/tenant.
"""
try:
logger.info("Creating tenant-independent subscription",
user_id=user_data.get('user_id'),
plan_id=plan_id)
# Step 1: Create customer in payment provider
customer = await self.payment_service.create_customer(user_data)
# Step 2: Handle coupon logic
trial_period_days = 0
coupon_discount = None
if coupon_code:
coupon_service = CouponService(self.db_session)
success, discount_applied, error = await coupon_service.redeem_coupon(
coupon_code,
None, # No tenant_id yet
base_trial_days=0
)
if success and discount_applied:
coupon_discount = discount_applied
trial_period_days = discount_applied.get("total_trial_days", 0)
# Step 3: Create subscription in payment provider
stripe_subscription = await self.payment_service.create_payment_subscription(
customer.id,
plan_id,
payment_method_id,
trial_period_days if trial_period_days > 0 else None,
billing_cycle
)
# Step 4: Create local subscription record WITHOUT tenant_id
subscription_record = await self.subscription_service.create_tenant_independent_subscription_record(
stripe_subscription.id,
customer.id,
plan_id,
stripe_subscription.status,
stripe_subscription.current_period_start,
stripe_subscription.current_period_end,
trial_period_days if trial_period_days > 0 else None,
billing_cycle,
user_data.get('user_id')
)
# Step 5: Store subscription in pending_tenant_linking state
await self.subscription_service.mark_subscription_as_pending_tenant_linking(
subscription_record.id,
user_data.get('user_id')
)
return {
"success": True,
"customer_id": customer.id,
"subscription_id": stripe_subscription.id,
"status": stripe_subscription.status,
"plan": plan_id,
"billing_cycle": billing_cycle,
"trial_period_days": trial_period_days,
"current_period_end": stripe_subscription.current_period_end.isoformat(),
"coupon_applied": bool(coupon_discount),
"user_id": user_data.get('user_id')
}
except Exception as e:
logger.error("Failed to create tenant-independent subscription",
error=str(e),
user_id=user_data.get('user_id'))
raise
```
**New Subscription Service Methods:**
```python
class SubscriptionService:
async def create_tenant_independent_subscription_record(
self,
subscription_id: str,
customer_id: str,
plan: str,
status: str,
current_period_start: datetime,
current_period_end: datetime,
trial_period_days: Optional[int] = None,
billing_cycle: str = "monthly",
user_id: Optional[str] = None
) -> Subscription:
"""Create subscription record without tenant_id"""
try:
subscription_data = {
"subscription_id": subscription_id,
"customer_id": customer_id,
"plan": plan,
"status": status,
"current_period_start": current_period_start,
"current_period_end": current_period_end,
"trial_period_days": trial_period_days,
"billing_cycle": billing_cycle,
"user_id": user_id,
"tenant_id": None, # No tenant linked yet
"is_tenant_linked": False,
"created_at": datetime.now(timezone.utc),
"updated_at": datetime.now(timezone.utc)
}
subscription = await self.subscription_repo.create(subscription_data)
logger.info("Tenant-independent subscription record created",
subscription_id=subscription.id,
user_id=user_id)
return subscription
except Exception as e:
logger.error("Failed to create tenant-independent subscription record",
error=str(e))
raise
async def mark_subscription_as_pending_tenant_linking(
self,
subscription_id: str,
user_id: str
):
"""Mark subscription as pending tenant linking"""
try:
await self.subscription_repo.update(
subscription_id,
{
"status": "pending_tenant_linking",
"tenant_linking_status": "pending",
"user_id": user_id
}
)
logger.info("Subscription marked as pending tenant linking",
subscription_id=subscription_id,
user_id=user_id)
except Exception as e:
logger.error("Failed to mark subscription as pending tenant linking",
error=str(e),
subscription_id=subscription_id)
raise
```
#### 4. Onboarding Flow Changes
**Enhanced RegisterTenantStep:**
```typescript
// When tenant is created, link the pending subscription
const handleSubmit = async () => {
if (!validateForm()) {
return;
}
try {
let tenant;
if (tenantId) {
// Update existing tenant
const updateData: TenantUpdate = { ... };
tenant = await updateTenant.mutateAsync({ tenantId, updateData });
} else {
// Create new tenant and link subscription
const registrationData: BakeryRegistrationWithSubscription = {
...formData,
// Include subscription linking data from onboarding progress
subscription_id: wizardContext.state.subscriptionId,
link_existing_subscription: true
};
tenant = await registerBakery.mutateAsync(registrationData);
}
// Continue with onboarding
onComplete({ tenant, tenantId: tenant.id });
} catch (error) {
console.error('Error registering bakery:', error);
setErrors({ submit: t('onboarding:steps.tenant_registration.errors.register') });
}
};
```
**Enhanced Tenant Creation Endpoint:**
```python
@router.post(route_builder.build_base_route("register", include_tenant_prefix=False))
async def register_bakery(
bakery_data: BakeryRegistrationWithSubscription,
current_user: Dict[str, Any] = Depends(get_current_user_dep),
tenant_service: EnhancedTenantService = Depends(get_enhanced_tenant_service),
db: AsyncSession = Depends(get_db)
):
"""Register a new bakery/tenant with subscription linking"""
try:
// Create tenant first
result = await tenant_service.create_bakery(bakery_data, current_user["user_id"])
tenant_id = result["tenant_id"]
// Check if we need to link an existing subscription
if bakery_data.link_existing_subscription and bakery_data.subscription_id:
// Link the pending subscription to this tenant
subscription_result = await tenant_service.link_subscription_to_tenant(
tenant_id,
bakery_data.subscription_id,
current_user["user_id"]
)
logger.info("Subscription linked to tenant during registration",
tenant_id=tenant_id,
subscription_id=bakery_data.subscription_id)
else:
// Fallback to current behavior for backward compatibility
// Create new subscription if needed
pass
return result
except Exception as e:
logger.error("Failed to register bakery with subscription linking",
error=str(e),
user_id=current_user["user_id"])
raise
```
**New Tenant Service Method for Subscription Linking:**
```python
class EnhancedTenantService:
async def link_subscription_to_tenant(
self,
tenant_id: str,
subscription_id: str,
user_id: str
) -> Dict[str, Any]:
"""Link a pending subscription to a tenant"""
try:
async with self.database_manager.get_session() as db_session:
async with UnitOfWork(db_session) as uow:
# Register repositories
subscription_repo = uow.register_repository(
"subscriptions", SubscriptionRepository, Subscription
)
tenant_repo = uow.register_repository(
"tenants", TenantRepository, Tenant
)
# Get the subscription
subscription = await subscription_repo.get_by_id(subscription_id)
if not subscription:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Subscription not found"
)
# Verify subscription is in pending_tenant_linking state
if subscription.tenant_linking_status != "pending":
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Subscription is not in pending tenant linking state"
)
# Verify subscription belongs to this user
if subscription.user_id != user_id:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Subscription does not belong to this user"
)
# Update subscription with tenant_id
update_data = {
"tenant_id": tenant_id,
"is_tenant_linked": True,
"tenant_linking_status": "completed",
"linked_at": datetime.now(timezone.utc)
}
await subscription_repo.update(subscription_id, update_data)
# Update tenant with subscription information
tenant_update = {
"stripe_customer_id": subscription.customer_id,
"subscription_status": subscription.status,
"subscription_plan": subscription.plan,
"subscription_tier": subscription.plan,
"billing_cycle": subscription.billing_cycle,
"trial_period_days": subscription.trial_period_days
}
await tenant_repo.update_tenant(tenant_id, tenant_update)
# Commit transaction
await uow.commit()
logger.info("Subscription successfully linked to tenant",
tenant_id=tenant_id,
subscription_id=subscription_id,
user_id=user_id)
return {
"success": True,
"tenant_id": tenant_id,
"subscription_id": subscription_id,
"status": "linked"
}
except Exception as e:
logger.error("Failed to link subscription to tenant",
error=str(e),
tenant_id=tenant_id,
subscription_id=subscription_id,
user_id=user_id)
raise
```
## Database Schema Changes
### New Subscription Table Structure
```sql
-- Add new columns to subscriptions table
ALTER TABLE subscriptions ADD COLUMN IF NOT EXISTS user_id UUID;
ALTER TABLE subscriptions ADD COLUMN IF NOT EXISTS is_tenant_linked BOOLEAN DEFAULT FALSE;
ALTER TABLE subscriptions ADD COLUMN IF NOT EXISTS tenant_linking_status VARCHAR(50);
ALTER TABLE subscriptions ADD COLUMN IF NOT EXISTS linked_at TIMESTAMP;
-- Add index for user-based subscription queries
CREATE INDEX IF NOT EXISTS idx_subscriptions_user_id ON subscriptions(user_id);
CREATE INDEX IF NOT EXISTS idx_subscriptions_linking_status ON subscriptions(tenant_linking_status);
-- Add constraint to ensure tenant_id is NULL when not linked
ALTER TABLE subscriptions ADD CONSTRAINT chk_tenant_linking
CHECK ((is_tenant_linked = FALSE AND tenant_id IS NULL) OR
(is_tenant_linked = TRUE AND tenant_id IS NOT NULL));
```
### Onboarding Progress Data Structure
```json
{
"user_id": "user-uuid",
"current_step": "user_registered",
"steps": [
{
"step_name": "user_registered",
"completed": true,
"completed_at": "2025-10-15T10:30:00Z",
"data": {
"subscription_id": "sub-uuid",
"subscription_plan": "professional",
"billing_cycle": "yearly",
"coupon_code": "PILOT2025",
"payment_method_id": "pm-123",
"payment_customer_id": "cus-456",
"status": "pending_tenant_linking",
"created_at": "2025-10-15T10:30:00Z"
}
}
]
}
```
## Error Handling & Recovery
### Error Scenarios and Recovery Strategies
1. **Payment Processing Failure**
- **Scenario**: Payment fails during registration
- **Recovery**: Rollback user creation, show error to user, allow retry
- **Implementation**: Transaction management in auth service
2. **Subscription Creation Failure**
- **Scenario**: Subscription creation fails after user creation
- **Recovery**: User created but marked as "registration_incomplete", allow retry in onboarding
- **Implementation**: Store registration state, provide recovery endpoint
3. **Tenant Linking Failure**
- **Scenario**: Tenant creation succeeds but subscription linking fails
- **Recovery**: Tenant created with default trial subscription, manual linking available
- **Implementation**: Fallback to current behavior, admin notification
4. **Orphaned Subscriptions**
- **Scenario**: User registers but never completes onboarding
- **Recovery**: Cleanup task to cancel subscriptions after 30 days
- **Implementation**: Background job to monitor pending subscriptions
### Monitoring and Alerts
```python
# Subscription linking monitoring
class SubscriptionMonitoringService:
async def monitor_pending_subscriptions(self):
"""Monitor subscriptions pending tenant linking"""
pending_subscriptions = await self.subscription_repo.get_pending_tenant_linking()
for subscription in pending_subscriptions:
created_days_ago = (datetime.now(timezone.utc) - subscription.created_at).days
if created_days_ago > 30:
# Cancel subscription and notify user
await self.cancel_orphaned_subscription(subscription.id)
await self.notify_user_about_cancellation(subscription.user_id)
elif created_days_ago > 7:
# Send reminder to complete onboarding
await self.send_onboarding_reminder(subscription.user_id)
```
## Migration Strategy
### Phase 1: Backend Implementation
1. **Database Migration**: Add new columns to subscriptions table
2. **Auth Service Updates**: Implement new registration endpoint
3. **Tenant Service Updates**: Implement tenant-independent subscription creation
4. **Shared Clients**: Update inter-service communication
### Phase 2: Frontend Implementation
1. **Registration Form**: Update to collect billing address
2. **Payment Flow**: Integrate with new backend endpoints
3. **Onboarding Flow**: Add subscription linking logic
### Phase 3: Testing and Validation
1. **Unit Tests**: Verify individual component behavior
2. **Integration Tests**: Test service-to-service communication
3. **End-to-End Tests**: Validate complete user journey
4. **Load Testing**: Ensure performance under load
### Phase 4: Deployment and Rollout
1. **Feature Flags**: Enable gradual rollout
2. **A/B Testing**: Compare with existing flow
3. **Monitoring**: Track key metrics and errors
4. **Rollback Plan**: Prepare for quick rollback if needed
## Benefits of the New Architecture
### 1. Improved User Experience
- **Clear Separation of Concerns**: Users understand each step of the process
- **Progressive Commitment**: Users can complete registration without immediate tenant creation
- **Flexible Onboarding**: Users can explore the platform before committing to a specific bakery
### 2. Better Error Handling
- **Isolated Failure Points**: Failures in one step don't cascade to others
- **Recovery Paths**: Clear recovery mechanisms for each failure scenario
- **Graceful Degradation**: System remains functional even with partial failures
### 3. Enhanced Business Flexibility
- **Multi-Tenant Support**: Users can create multiple tenants with the same subscription
- **Subscription Portability**: Subscriptions can be moved between tenants
- **Trial Management**: Better control over trial periods and conversions
### 4. Improved Security
- **Data Isolation**: Sensitive payment data handled separately from user data
- **Audit Trails**: Clear tracking of subscription lifecycle
- **Compliance**: Better support for GDPR and payment industry standards
### 5. Scalability
- **Microservice Alignment**: Better separation between auth and tenant services
- **Independent Scaling**: Services can be scaled independently
- **Future Extensibility**: Easier to add new features and integrations
## Implementation Timeline
| Phase | Duration | Key Activities |
|-------|----------|----------------|
| 1. Analysis & Design | 2 weeks | Architecture review, technical design, stakeholder approval |
| 2. Backend Implementation | 4 weeks | Database changes, service updates, API development |
| 3. Frontend Implementation | 3 weeks | Form updates, payment integration, onboarding changes |
| 4. Testing & QA | 3 weeks | Unit tests, integration tests, E2E tests, performance testing |
| 5. Deployment & Rollout | 2 weeks | Staging deployment, production rollout, monitoring setup |
| 6. Post-Launch | Ongoing | Bug fixes, performance optimization, feature enhancements |
## Risks and Mitigation
### Technical Risks
1. **Data Consistency**: Risk of inconsistent state between services
- *Mitigation*: Strong transaction management, idempotent operations, reconciliation jobs
2. **Performance Impact**: Additional service calls may impact performance
- *Mitigation*: Caching, async processing, performance optimization
3. **Complexity Increase**: More moving parts increase system complexity
- *Mitigation*: Clear documentation, comprehensive monitoring, gradual rollout
### Business Risks
1. **User Confusion**: Multi-step process may confuse some users
- *Mitigation*: Clear UI guidance, progress indicators, help documentation
2. **Conversion Impact**: Additional steps may reduce conversion rates
- *Mitigation*: A/B testing, user feedback, iterative improvements
3. **Support Burden**: New flow may require additional support
- *Mitigation*: Comprehensive documentation, self-service recovery, support training
## Success Metrics
### Key Performance Indicators
1. **Registration Completion Rate**: Percentage of users completing registration
2. **Onboarding Completion Rate**: Percentage of users completing onboarding
3. **Error Rates**: Frequency of errors in each step
4. **Conversion Rates**: Percentage of visitors becoming paying customers
5. **User Satisfaction**: Feedback and ratings from users
### Monitoring Dashboard
```
Registration Funnel:
- Step 1 (Basic Info): 100%
- Step 2 (Plan Selection): 85%
- Step 3 (Payment): 75%
- Onboarding Completion: 60%
Error Metrics:
- Registration Errors: < 1%
- Payment Errors: < 2%
- Subscription Linking Errors: < 0.5%
Performance Metrics:
- Registration Time: < 5s
- Payment Processing Time: < 3s
- Tenant Creation Time: < 2s
```
## Conclusion
This rearchitecture proposal addresses the current limitations by implementing a clear separation between user registration, payment processing, and tenant creation. The new multi-phase approach provides better user experience, improved error handling, and enhanced business flexibility while maintaining backward compatibility and providing clear migration paths.
The proposed solution aligns with modern microservice architectures and provides a solid foundation for future growth and feature enhancements.

View File

@@ -242,7 +242,21 @@ Your application validates the `PILOT2025` coupon code and, when valid:
- Schedules the first invoice for day 91
- Automatically begins normal billing after trial ends
**IMPORTANT:** You do NOT need to create a coupon in Stripe Dashboard. The coupon is managed entirely in your application's database. Stripe only needs to know about the trial period duration (90 days).
**IMPORTANT:** You do NOT need to create a coupon in Stripe Dashboard. The coupon is managed entirely in your application's database.
**How it works with Stripe:**
- Your application validates the `PILOT2025` coupon code against your database
- If valid, your backend passes `trial_period_days=90` parameter when creating the Stripe subscription
- Stripe doesn't know about the "PILOT2025" coupon itself - it only receives the trial duration
- Example API call to Stripe:
```python
stripe.Subscription.create(
customer=customer_id,
items=[{"price": price_id}],
trial_period_days=90, # <-- This is what Stripe needs
# No coupon parameter needed in Stripe
)
```
#### Verify PILOT2025 Coupon in Your Database:
@@ -308,12 +322,34 @@ The backend coupon configuration is managed in code at [services/tenant/app/jobs
### Step 3: Configure Webhooks
1. Navigate to **Developers****Webhooks**
**Important:** For local development, you'll use **Stripe CLI** instead of creating an endpoint in the Stripe Dashboard. The CLI automatically forwards webhook events to your local server.
#### For Local Development (Recommended):
**Use Stripe CLI** - See [Webhook Testing Section](#webhook-testing) below for detailed setup.
Quick start:
```bash
# Install Stripe CLI
brew install stripe/stripe-cli/stripe # macOS
# Login to Stripe
stripe login
# Forward webhooks to gateway
stripe listen --forward-to https://bakery-ia.local/api/v1/stripe
```
The CLI will provide a webhook signing secret. See the [Webhook Testing](#webhook-testing) section for complete instructions on updating your configuration.
#### For Production or Public Testing:
1. Navigate to **Developers** → **Webhooks** in Stripe Dashboard
2. Click **+ Add endpoint**
3. **For Local Development:**
- Endpoint URL: `https://your-ngrok-url.ngrok.io/webhooks/stripe`
- (We'll set up ngrok later for local testing)
3. **Endpoint URL:**
- Production: `https://yourdomain.com/api/v1/stripe`
- Or use ngrok for testing: `https://your-ngrok-url.ngrok.io/api/v1/stripe`
4. **Select events to listen to:**
- `checkout.session.completed`
@@ -1080,8 +1116,13 @@ This opens a browser to authorize the CLI.
#### Step 3: Forward Webhooks to Local Server
**For Development with Stripe CLI:**
The Stripe CLI creates a secure tunnel to forward webhook events from Stripe's servers to your local development environment.
```bash
stripe listen --forward-to localhost:8000/webhooks/stripe
# Forward webhook events to your gateway (which proxies to tenant service)
stripe listen --forward-to https://bakery-ia.local/api/v1/stripe
```
**Expected Output:**
@@ -1089,10 +1130,28 @@ stripe listen --forward-to localhost:8000/webhooks/stripe
> Ready! Your webhook signing secret is whsec_abc123... (^C to quit)
```
**Important:** Copy this webhook signing secret and add it to your backend `.env`:
```bash
STRIPE_WEBHOOK_SECRET=whsec_abc123...
```
**Important - Update Your Configuration:**
1. **Copy the webhook signing secret** provided by `stripe listen`
2. **Encode it for Kubernetes:**
```bash
echo -n "whsec_abc123..." | base64
```
3. **Update secrets.yaml:**
```bash
# Edit infrastructure/kubernetes/base/secrets.yaml
# Update the STRIPE_WEBHOOK_SECRET with the base64 value
```
4. **Apply to your cluster:**
```bash
kubectl apply -f infrastructure/kubernetes/base/secrets.yaml
kubectl rollout restart deployment/tenant-service -n bakery-ia
```
**Note:** The webhook secret from `stripe listen` is temporary and only works while the CLI is running. Each time you restart `stripe listen`, you'll get a new webhook secret.
#### Step 4: Trigger Test Events

View File

@@ -13,6 +13,7 @@ import {
TenantSearchParams,
TenantNearbyParams,
AddMemberWithUserCreate,
BakeryRegistrationWithSubscription,
} from '../types/tenant';
import { ApiError } from '../client';
@@ -170,6 +171,24 @@ export const useRegisterBakery = (
});
};
export const useRegisterBakeryWithSubscription = (
options?: UseMutationOptions<TenantResponse, ApiError, BakeryRegistrationWithSubscription>
) => {
const queryClient = useQueryClient();
return useMutation<TenantResponse, ApiError, BakeryRegistrationWithSubscription>({
mutationFn: (bakeryData: BakeryRegistrationWithSubscription) => tenantService.registerBakeryWithSubscription(bakeryData),
onSuccess: (data, variables) => {
// Invalidate user tenants to include the new one
queryClient.invalidateQueries({ queryKey: tenantKeys.userTenants('') });
queryClient.invalidateQueries({ queryKey: tenantKeys.userOwnedTenants('') });
// Set the tenant data in cache
queryClient.setQueryData(tenantKeys.detail(data.id), data);
},
...options,
});
};
export const useUpdateTenant = (
options?: UseMutationOptions<TenantResponse, ApiError, { tenantId: string; updateData: TenantUpdate }>
) => {

View File

@@ -37,6 +37,10 @@ export class AuthService {
return apiClient.post<TokenResponse>(`${this.baseUrl}/register`, userData);
}
async registerWithSubscription(userData: UserRegistration): Promise<UserRegistrationWithSubscriptionResponse> {
return apiClient.post<UserRegistrationWithSubscriptionResponse>(`${this.baseUrl}/register-with-subscription`, userData);
}
async login(loginData: UserLogin): Promise<TokenResponse> {
return apiClient.post<TokenResponse>(`${this.baseUrl}/login`, loginData);
}

View File

@@ -14,6 +14,7 @@
import { apiClient } from '../client';
import {
BakeryRegistration,
BakeryRegistrationWithSubscription,
TenantResponse,
TenantAccessResponse,
TenantUpdate,
@@ -22,6 +23,7 @@ import {
TenantSearchParams,
TenantNearbyParams,
AddMemberWithUserCreate,
SubscriptionLinkingResponse,
} from '../types/tenant';
export class TenantService {
@@ -35,6 +37,21 @@ export class TenantService {
return apiClient.post<TenantResponse>(`${this.baseUrl}/register`, bakeryData);
}
async registerBakeryWithSubscription(bakeryData: BakeryRegistrationWithSubscription): Promise<TenantResponse> {
return apiClient.post<TenantResponse>(`${this.baseUrl}/register`, bakeryData);
}
async linkSubscriptionToTenant(
tenantId: string,
subscriptionId: string,
userId: string
): Promise<SubscriptionLinkingResponse> {
return apiClient.post<SubscriptionLinkingResponse>(
`${this.baseUrl}/subscriptions/link`,
{ tenant_id: tenantId, subscription_id: subscriptionId, user_id: userId }
);
}
async getTenant(tenantId: string): Promise<TenantResponse> {
return apiClient.get<TenantResponse>(`${this.baseUrl}/${tenantId}`);
}

View File

@@ -1,4 +1,30 @@
// frontend/src/api/types/auth.ts
// ================================================================
/**
* Authentication Type Definitions
*
* Aligned with backend schemas:
* - services/auth/app/schemas/auth.py
* - services/auth/app/schemas/users.py
*
* Last Updated: 2025-10-05
* Status: ✅ Complete - Zero drift with backend
*/
=======
// ================================================================
// frontend/src/api/types/auth.ts
// ================================================================
/**
* Authentication Type Definitions
*
* Aligned with backend schemas:
* - services/auth/app/schemas/auth.py
* - services/auth/app/schemas/users.py
*
* Last Updated: 2025-10-13
* Status: ✅ Complete - Zero drift with backend
* Changes: Removed use_trial, added payment_customer_id and default_payment_method_id
*/================================================================
// frontend/src/api/types/auth.ts
// ================================================================
/**
@@ -27,7 +53,7 @@ export interface UserRegistration {
tenant_name?: string | null; // max_length=255
role?: string | null; // Default: "admin", pattern: ^(user|admin|manager|super_admin)$
subscription_plan?: string | null; // Default: "starter", options: starter, professional, enterprise
use_trial?: boolean | null; // Default: false - Whether to use trial period
billing_cycle?: 'monthly' | 'yearly' | null; // Default: "monthly" - Billing cycle preference
payment_method_id?: string | null; // Stripe payment method ID
coupon_code?: string | null; // Promotional coupon code for discounts/trial extensions
// GDPR Consent fields
@@ -35,6 +61,20 @@ export interface UserRegistration {
privacy_accepted?: boolean; // Default: true - Accept privacy policy
marketing_consent?: boolean; // Default: false - Consent to marketing communications
analytics_consent?: boolean; // Default: false - Consent to analytics cookies
// NEW: Billing address fields for subscription creation
address?: string | null; // Billing address
postal_code?: string | null; // Billing postal code
city?: string | null; // Billing city
country?: string | null; // Billing country
}
/**
* User registration with subscription response
* Extended token response for registration with subscription
* Backend: services/auth/app/schemas/auth.py:70-80 (TokenResponse with subscription_id)
*/
export interface UserRegistrationWithSubscriptionResponse extends TokenResponse {
subscription_id?: string | null; // ID of the created subscription (returned if subscription was created during registration)
}
/**
@@ -165,6 +205,8 @@ export interface UserResponse {
timezone?: string | null;
tenant_id?: string | null;
role?: string | null; // Default: "admin"
payment_customer_id?: string | null; // Payment provider customer ID (Stripe, etc.)
default_payment_method_id?: string | null; // Default payment method ID
}
/**

View File

@@ -255,3 +255,38 @@ export interface TenantNearbyParams {
radius_km?: number;
limit?: number;
}
// ================================================================
// NEW ARCHITECTURE: TENANT-INDEPENDENT SUBSCRIPTION TYPES
// ================================================================
/**
* Subscription linking request for new registration flow
* Backend: services/tenant/app/api/tenant_operations.py
*/
export interface SubscriptionLinkingRequest {
tenant_id: string; // Tenant ID to link subscription to
subscription_id: string; // Subscription ID to link
user_id: string; // User ID performing the linking
}
/**
* Subscription linking response
*/
export interface SubscriptionLinkingResponse {
success: boolean;
message: string;
data?: {
tenant_id: string;
subscription_id: string;
status: string;
};
}
/**
* Extended BakeryRegistration with subscription linking support
*/
export interface BakeryRegistrationWithSubscription extends BakeryRegistration {
subscription_id?: string | null; // Optional subscription ID to link
link_existing_subscription?: boolean | null; // Flag to link existing subscription
}

View File

@@ -12,6 +12,7 @@ interface PaymentFormProps {
onBypassToggle?: () => void;
userName?: string;
userEmail?: string;
isProcessingRegistration?: boolean; // External loading state from parent (registration in progress)
}
const PaymentForm: React.FC<PaymentFormProps> = ({
@@ -21,7 +22,8 @@ const PaymentForm: React.FC<PaymentFormProps> = ({
bypassPayment = false,
onBypassToggle,
userName = '',
userEmail = ''
userEmail = '',
isProcessingRegistration = false
}) => {
const { t } = useTranslation();
const stripe = useStripe();
@@ -57,12 +59,17 @@ const PaymentForm: React.FC<PaymentFormProps> = ({
return;
}
if (bypassPayment) {
// In development mode, bypass payment processing
if (bypassPayment && import.meta.env.MODE === 'development') {
// DEVELOPMENT ONLY: Bypass payment processing
onPaymentSuccess();
return;
}
if (bypassPayment && import.meta.env.MODE === 'production') {
onPaymentError('Payment bypass is not allowed in production');
return;
}
setLoading(true);
setError(null);
@@ -101,13 +108,16 @@ const PaymentForm: React.FC<PaymentFormProps> = ({
console.log('Payment method created:', paymentMethod);
// Pass the payment method ID to the parent component for server-side processing
// Keep loading state active - parent will handle the full registration flow
onPaymentSuccess(paymentMethod?.id);
// DON'T set loading to false here - let parent component control the loading state
// The registration with backend will happen next, and we want to keep button disabled
} catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Error desconocido al procesar el pago';
setError(errorMessage);
onPaymentError(errorMessage);
} finally {
setLoading(false);
setLoading(false); // Only reset loading on error
}
};
@@ -128,24 +138,26 @@ const PaymentForm: React.FC<PaymentFormProps> = ({
</p>
</div>
{/* Development mode toggle */}
<div className="mb-6 p-3 bg-yellow-50 border border-yellow-200 rounded-lg flex items-center justify-between">
<div className="flex items-center gap-2">
<Lock className="w-4 h-4 text-yellow-600" />
<span className="text-sm text-yellow-800">
{t('auth:payment.dev_mode', 'Modo Desarrollo')}
</span>
{/* Development mode toggle - only shown in development */}
{import.meta.env.MODE === 'development' && (
<div className="mb-6 p-3 bg-yellow-50 border border-yellow-200 rounded-lg flex items-center justify-between">
<div className="flex items-center gap-2">
<Lock className="w-4 h-4 text-yellow-600" />
<span className="text-sm text-yellow-800">
{t('auth:payment.dev_mode', 'Modo Desarrollo')}
</span>
</div>
<Button
variant={bypassPayment ? "primary" : "outline"}
size="sm"
onClick={handleBypassPayment}
>
{bypassPayment
? t('auth:payment.payment_bypassed', 'Pago Bypassed')
: t('auth:payment.bypass_payment', 'Bypass Pago')}
</Button>
</div>
<Button
variant={bypassPayment ? "primary" : "outline"}
size="sm"
onClick={handleBypassPayment}
>
{bypassPayment
? t('auth:payment.payment_bypassed', 'Pago Bypassed')
: t('auth:payment.bypass_payment', 'Bypass Pago')}
</Button>
</div>
)}
{!bypassPayment && (
<form onSubmit={handleSubmit}>
@@ -259,9 +271,9 @@ const PaymentForm: React.FC<PaymentFormProps> = ({
type="submit"
variant="primary"
size="lg"
isLoading={loading}
loadingText="Procesando pago..."
disabled={!stripe || loading || (!cardComplete && !bypassPayment)}
isLoading={loading || isProcessingRegistration}
loadingText={isProcessingRegistration ? "Creando tu cuenta..." : "Procesando pago..."}
disabled={!stripe || loading || isProcessingRegistration || (!cardComplete && !bypassPayment)}
className="w-full"
>
{t('auth:payment.process_payment', 'Procesar Pago')}

View File

@@ -41,6 +41,11 @@ interface SimpleUserRegistration {
acceptTerms: boolean;
marketingConsent: boolean;
analyticsConsent: boolean;
// NEW: Billing address fields for subscription creation
address?: string;
postal_code?: string;
city?: string;
country?: string;
}
// Define the steps for the registration process
@@ -59,14 +64,19 @@ export const RegisterForm: React.FC<RegisterFormProps> = ({
confirmPassword: '',
acceptTerms: false,
marketingConsent: false,
analyticsConsent: false
analyticsConsent: false,
// NEW: Initialize billing address fields
address: '',
postal_code: '',
city: '',
country: ''
});
const [errors, setErrors] = useState<Partial<SimpleUserRegistration>>({});
const [showPassword, setShowPassword] = useState(false);
const [showConfirmPassword, setShowConfirmPassword] = useState(false);
const { register } = useAuthActions();
const { register, registerWithSubscription } = useAuthActions();
const isLoading = useAuthLoading();
const error = useAuthError();
@@ -74,17 +84,27 @@ export const RegisterForm: React.FC<RegisterFormProps> = ({
// Detect pilot program participation
const { isPilot, couponCode, trialMonths } = usePilotDetection();
// Read URL parameters for plan persistence
// Read URL parameters for plan and billing cycle persistence
const [searchParams] = useSearchParams();
const preSelectedPlan = searchParams.get('plan') as string | null; // starter | professional | enterprise
// Validate plan parameter
const VALID_PLANS = ['starter', 'professional', 'enterprise'];
const planParam = searchParams.get('plan');
const preSelectedPlan = (planParam && VALID_PLANS.includes(planParam)) ? planParam : null;
const urlPilotParam = searchParams.get('pilot') === 'true';
// Validate billing cycle parameter
const billingParam = searchParams.get('billing_cycle');
const urlBillingCycle = (billingParam === 'monthly' || billingParam === 'yearly') ? billingParam : null;
// Multi-step form state
const [currentStep, setCurrentStep] = useState<RegistrationStep>('basic_info');
const [selectedPlan, setSelectedPlan] = useState<string>(preSelectedPlan || 'starter');
const [useTrial, setUseTrial] = useState<boolean>(isPilot || urlPilotParam); // Auto-enable trial for pilot customers
const [bypassPayment, setBypassPayment] = useState<boolean>(false);
const [selectedPlanMetadata, setSelectedPlanMetadata] = useState<any>(null);
const [billingCycle, setBillingCycle] = useState<'monthly' | 'yearly'>(urlBillingCycle || 'monthly'); // Track billing cycle, default to URL value if present
// Helper function to determine password match status
const getPasswordMatchStatus = () => {
@@ -205,7 +225,7 @@ export const RegisterForm: React.FC<RegisterFormProps> = ({
password: formData.password,
tenant_name: 'Default Bakery', // Default value since we're not collecting it
subscription_plan: selectedPlan,
use_trial: useTrial,
billing_cycle: billingCycle, // Add billing cycle selection
payment_method_id: paymentMethodId,
// Include coupon code if pilot customer
coupon_code: isPilot ? couponCode : undefined,
@@ -214,14 +234,15 @@ export const RegisterForm: React.FC<RegisterFormProps> = ({
privacy_accepted: formData.acceptTerms,
marketing_consent: formData.marketingConsent,
analytics_consent: formData.analyticsConsent,
// NEW: Include billing address data for subscription creation
address: formData.address,
postal_code: formData.postal_code,
city: formData.city,
country: formData.country,
};
await register(registrationData);
// CRITICAL: Store subscription_tier in localStorage for onboarding flow
// This is required for conditional step rendering in UnifiedOnboardingWizard
console.log('💾 Storing subscription_tier in localStorage:', selectedPlan);
localStorage.setItem('subscription_tier', selectedPlan);
// Use the new registration endpoint with subscription creation
await registerWithSubscription(registrationData);
const successMessage = isPilot
? '¡Bienvenido al programa piloto! Tu cuenta ha sido creada con 3 meses gratis.'
@@ -232,14 +253,14 @@ export const RegisterForm: React.FC<RegisterFormProps> = ({
});
onSuccess?.();
} catch (err) {
showToast.error(error || t('auth:register.register_button', 'No se pudo crear la cuenta. Verifica que el email no esté en uso.'), {
showToast.error(error || t('auth:register.register_button', 'No se pudo crear la account. Verifica que el email no esté en uso.'), {
title: t('auth:alerts.error_create', 'Error al crear la cuenta')
});
}
};
const handlePaymentSuccess = () => {
handleRegistrationSubmit(); // In a real app, you would pass the payment method ID
const handlePaymentSuccess = (paymentMethodId?: string) => {
handleRegistrationSubmit(paymentMethodId);
};
const handlePaymentError = (errorMessage: string) => {
@@ -617,6 +638,35 @@ export const RegisterForm: React.FC<RegisterFormProps> = ({
</p>
</div>
{/* Billing Cycle Toggle */}
<div className="flex justify-center">
<div className="inline-flex rounded-lg border-2 border-[var(--border-primary)] p-1 bg-[var(--bg-secondary)]">
<button
onClick={() => setBillingCycle('monthly')}
className={`px-6 py-2 rounded-md text-sm font-semibold transition-all ${
billingCycle === 'monthly'
? 'bg-[var(--color-primary)] text-white shadow-md'
: 'text-[var(--text-secondary)] hover:bg-[var(--bg-primary)] hover:text-[var(--text-primary)]'
}`}
>
{t('billing.monthly', 'Mensual')}
</button>
<button
onClick={() => setBillingCycle('yearly')}
className={`px-6 py-2 rounded-md text-sm font-semibold transition-all flex items-center gap-2 ${
billingCycle === 'yearly'
? 'bg-[var(--color-primary)] text-white shadow-md'
: 'text-[var(--text-secondary)] hover:bg-[var(--bg-primary)] hover:text-[var(--text-primary)]'
}`}
>
{t('billing.yearly', 'Anual')}
<span className="text-xs font-bold text-green-600 dark:text-green-400">
{t('billing.save_percent', 'Ahorra 17%')}
</span>
</button>
</div>
</div>
<SubscriptionPricingCards
mode="selection"
selectedPlan={selectedPlan}
@@ -624,6 +674,7 @@ export const RegisterForm: React.FC<RegisterFormProps> = ({
showPilotBanner={isPilot}
pilotCouponCode={couponCode}
pilotTrialMonths={trialMonths}
billingCycle={billingCycle} // Pass the selected billing cycle
/>
<div className="flex flex-col sm:flex-row justify-between gap-3 sm:gap-4 pt-2 border-t border-border-primary">
@@ -674,9 +725,25 @@ export const RegisterForm: React.FC<RegisterFormProps> = ({
<span className="font-bold text-color-primary text-lg">{selectedPlanMetadata.name}</span>
</div>
<div className="flex justify-between items-center">
<span className="text-text-secondary">{t('auth:payment.monthly_price', 'Precio mensual:')}</span>
<span className="text-text-secondary">{t('auth:payment.billing_cycle', 'Ciclo de facturación:')}</span>
<span className="font-semibold text-text-primary capitalize">
{billingCycle === 'monthly'
? t('billing.monthly', 'Mensual')
: t('billing.yearly', 'Anual')}
</span>
</div>
<div className="flex justify-between items-center">
<span className="text-text-secondary">
{billingCycle === 'monthly'
? t('auth:payment.monthly_price', 'Precio mensual:')
: t('auth:payment.yearly_price', 'Precio anual:')}
</span>
<span className="font-semibold text-text-primary">
{subscriptionService.formatPrice(selectedPlanMetadata.monthly_price)}/mes
{subscriptionService.formatPrice(
billingCycle === 'monthly'
? selectedPlanMetadata.monthly_price
: selectedPlanMetadata.yearly_price
)}{billingCycle === 'monthly' ? '/mes' : '/año'}
</span>
</div>
{useTrial && (
@@ -694,7 +761,7 @@ export const RegisterForm: React.FC<RegisterFormProps> = ({
</div>
<p className="text-xs text-text-tertiary mt-2 text-center">
{useTrial
? t('auth:payment.billing_message', {price: subscriptionService.formatPrice(selectedPlanMetadata.monthly_price)})
? t('auth:payment.billing_message', {price: subscriptionService.formatPrice(billingCycle === 'monthly' ? selectedPlanMetadata.monthly_price : selectedPlanMetadata.yearly_price)})
: t('auth:payment.payment_required')
}
</p>
@@ -725,6 +792,7 @@ export const RegisterForm: React.FC<RegisterFormProps> = ({
onPaymentError={handlePaymentError}
bypassPayment={bypassPayment}
onBypassToggle={() => setBypassPayment(!bypassPayment)}
isProcessingRegistration={isLoading}
/>
</Elements>

View File

@@ -2,11 +2,13 @@ import React, { useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { Button, Input } from '../../../ui';
import { AddressAutocomplete } from '../../../ui/AddressAutocomplete';
import { useRegisterBakery, useTenant, useUpdateTenant } from '../../../../api/hooks/tenant';
import { BakeryRegistration, TenantUpdate } from '../../../../api/types/tenant';
import { useRegisterBakery, useTenant, useUpdateTenant, useRegisterBakeryWithSubscription } from '../../../../api/hooks/tenant';
import { BakeryRegistration, TenantUpdate, BakeryRegistrationWithSubscription } from '../../../../api/types/tenant';
import { AddressResult } from '../../../../services/api/geocodingApi';
import { useWizardContext } from '../context';
import { poiContextApi } from '../../../../services/api/poiContextApi';
import { useAuthStore } from '../../../../stores/auth.store';
import { useUserProgress } from '../../../../api/hooks/onboarding';
interface RegisterTenantStepProps {
onNext: () => void;
@@ -38,6 +40,31 @@ export const RegisterTenantStep: React.FC<RegisterTenantStepProps> = ({
const wizardContext = useWizardContext();
const tenantId = wizardContext.state.tenantId;
// Get pending subscription ID from auth store (primary source)
const { pendingSubscriptionId: authStoreSubscriptionId, setPendingSubscriptionId, user } = useAuthStore(state => ({
pendingSubscriptionId: state.pendingSubscriptionId,
setPendingSubscriptionId: state.setPendingSubscriptionId,
user: state.user
}));
// Fallback: Fetch from onboarding progress API if not in auth store
const { data: onboardingProgress } = useUserProgress(user?.id || '');
// Find the user_registered step in the onboarding progress
const userRegisteredStep = onboardingProgress?.steps?.find(step => step.step_name === 'user_registered');
const subscriptionIdFromProgress = userRegisteredStep?.data?.subscription_id || null;
// Determine the subscription ID to use (auth store takes precedence, fallback to onboarding progress)
const pendingSubscriptionId = authStoreSubscriptionId || subscriptionIdFromProgress;
// Sync auth store with onboarding progress if auth store is empty but onboarding has it
useEffect(() => {
if (!authStoreSubscriptionId && subscriptionIdFromProgress) {
console.log('🔄 Syncing subscription ID from onboarding progress to auth store:', subscriptionIdFromProgress);
setPendingSubscriptionId(subscriptionIdFromProgress);
}
}, [authStoreSubscriptionId, subscriptionIdFromProgress, setPendingSubscriptionId]);
// Check if user is enterprise tier for conditional labels
const subscriptionTier = localStorage.getItem('subscription_tier');
const isEnterprise = subscriptionTier === 'enterprise';
@@ -191,9 +218,30 @@ export const RegisterTenantStep: React.FC<RegisterTenantStepProps> = ({
tenant = await updateTenant.mutateAsync({ tenantId, updateData });
console.log('✅ Tenant updated successfully:', tenant.id);
} else {
// Create new tenant
tenant = await registerBakery.mutateAsync(formData);
console.log('✅ Tenant registered successfully:', tenant.id);
// Check if we have a pending subscription to link (from auth store)
if (pendingSubscriptionId) {
console.log('🔗 Found pending subscription in auth store, linking to new tenant:', {
subscriptionId: pendingSubscriptionId
});
// Create tenant with subscription linking
const registrationData: BakeryRegistrationWithSubscription = {
...formData,
subscription_id: pendingSubscriptionId,
link_existing_subscription: true
};
tenant = await registerBakeryWithSubscription.mutateAsync(registrationData);
console.log('✅ Tenant registered with subscription linking:', tenant.id);
// Clean up pending subscription ID from store after successful linking
setPendingSubscriptionId(null);
console.log('🧹 Cleaned up subscription data from auth store');
} else {
// Create new tenant without subscription linking (fallback)
tenant = await registerBakery.mutateAsync(formData);
console.log('✅ Tenant registered successfully (no subscription linking):', tenant.id);
}
}
// Trigger POI detection in the background (non-blocking)

View File

@@ -45,12 +45,25 @@ export const PaymentMethodUpdateModal: React.FC<PaymentMethodUpdateModalProps> =
if (isOpen) {
const loadStripe = async () => {
try {
// Get Stripe publishable key from runtime config or build-time env
const getStripePublishableKey = () => {
if (typeof window !== 'undefined' && (window as any).__RUNTIME_CONFIG__?.VITE_STRIPE_PUBLISHABLE_KEY) {
return (window as any).__RUNTIME_CONFIG__.VITE_STRIPE_PUBLISHABLE_KEY;
}
return import.meta.env.VITE_STRIPE_PUBLISHABLE_KEY;
};
const stripeKey = getStripePublishableKey();
if (!stripeKey) {
throw new Error('Stripe publishable key not configured');
}
// Load Stripe.js from CDN
const stripeScript = document.createElement('script');
stripeScript.src = 'https://js.stripe.com/v3/';
stripeScript.async = true;
stripeScript.onload = () => {
const stripeInstance = (window as any).Stripe('pk_test_your_publishable_key'); // Replace with actual key
const stripeInstance = (window as any).Stripe(stripeKey);
setStripe(stripeInstance);
const elementsInstance = stripeInstance.elements();
setElements(elementsInstance);
@@ -61,7 +74,7 @@ export const PaymentMethodUpdateModal: React.FC<PaymentMethodUpdateModalProps> =
setError('Failed to load payment processor');
}
};
loadStripe();
}
}, [isOpen]);

View File

@@ -13,6 +13,7 @@ export const PricingSection: React.FC = () => {
const navigate = useNavigate();
const [showComparisonModal, setShowComparisonModal] = useState(false);
const [plans, setPlans] = useState<Record<SubscriptionTier, PlanMetadata> | null>(null);
const [billingCycle, setBillingCycle] = useState<'monthly' | 'yearly'>('monthly');
useEffect(() => {
loadPlans();
@@ -28,17 +29,48 @@ export const PricingSection: React.FC = () => {
};
const handlePlanSelect = (tier: string) => {
navigate(getRegisterUrl(tier));
// Use the updated getRegisterUrl function that supports billing cycle
navigate(getRegisterUrl(tier, billingCycle));
};
return (
<div>
{/* Billing Cycle Toggle */}
<div className="flex justify-center mb-8">
<div className="inline-flex rounded-lg border-2 border-[var(--border-primary)] p-1 bg-[var(--bg-secondary)]">
<button
onClick={() => setBillingCycle('monthly')}
className={`px-6 py-2 rounded-md text-sm font-semibold transition-all ${
billingCycle === 'monthly'
? 'bg-[var(--color-primary)] text-white shadow-md'
: 'text-[var(--text-secondary)] hover:bg-[var(--bg-primary)] hover:text-[var(--text-primary)]'
}`}
>
{t('billing.monthly', 'Mensual')}
</button>
<button
onClick={() => setBillingCycle('yearly')}
className={`px-6 py-2 rounded-md text-sm font-semibold transition-all flex items-center gap-2 ${
billingCycle === 'yearly'
? 'bg-[var(--color-primary)] text-white shadow-md'
: 'text-[var(--text-secondary)] hover:bg-[var(--bg-primary)] hover:text-[var(--text-primary)]'
}`}
>
{t('billing.yearly', 'Anual')}
<span className="text-xs font-bold text-green-600 dark:text-green-400">
{t('billing.save_percent', 'Ahorra 17%')}
</span>
</button>
</div>
</div>
{/* Pricing Cards */}
<SubscriptionPricingCards
mode="landing"
showPilotBanner={true}
pilotTrialMonths={3}
showComparison={false}
billingCycle={billingCycle} // Pass selected billing cycle
/>
{/* Feature Comparison Link */}

View File

@@ -7,6 +7,7 @@ import {
subscriptionService,
type PlanMetadata,
type SubscriptionTier,
type BillingCycle,
SUBSCRIPTION_TIERS
} from '../../api';
import { getRegisterUrl } from '../../utils/navigation';
@@ -23,6 +24,8 @@ interface SubscriptionPricingCardsProps {
pilotTrialMonths?: number;
showComparison?: boolean;
className?: string;
billingCycle?: BillingCycle;
onBillingCycleChange?: (cycle: BillingCycle) => void;
}
export const SubscriptionPricingCards: React.FC<SubscriptionPricingCardsProps> = ({
@@ -33,14 +36,19 @@ export const SubscriptionPricingCards: React.FC<SubscriptionPricingCardsProps> =
pilotCouponCode,
pilotTrialMonths = 3,
showComparison = false,
className = ''
className = '',
billingCycle: externalBillingCycle,
onBillingCycleChange
}) => {
const { t } = useTranslation('subscription');
const [plans, setPlans] = useState<Record<SubscriptionTier, PlanMetadata> | null>(null);
const [billingCycle, setBillingCycle] = useState<BillingCycle>('monthly');
const [internalBillingCycle, setInternalBillingCycle] = useState<BillingCycle>('monthly');
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
// Use external billing cycle if provided, otherwise use internal state
const billingCycle = externalBillingCycle || internalBillingCycle;
useEffect(() => {
loadPlans();
}, []);
@@ -145,34 +153,48 @@ export const SubscriptionPricingCards: React.FC<SubscriptionPricingCardsProps> =
</Card>
)}
{/* Billing Cycle Toggle */}
<div className="flex justify-center mb-8">
<div className="inline-flex rounded-lg border-2 border-[var(--border-primary)] p-1 bg-[var(--bg-secondary)]">
<button
onClick={() => setBillingCycle('monthly')}
className={`px-6 py-2 rounded-md text-sm font-semibold transition-all ${
billingCycle === 'monthly'
? 'bg-[var(--color-primary)] text-white shadow-md'
: 'text-[var(--text-secondary)] hover:bg-[var(--bg-primary)] hover:text-[var(--text-primary)]'
}`}
>
{t('billing.monthly', 'Mensual')}
</button>
<button
onClick={() => setBillingCycle('yearly')}
className={`px-6 py-2 rounded-md text-sm font-semibold transition-all flex items-center gap-2 ${
billingCycle === 'yearly'
? 'bg-[var(--color-primary)] text-white shadow-md'
: 'text-[var(--text-secondary)] hover:bg-[var(--bg-primary)] hover:text-[var(--text-primary)]'
}`}
>
{t('billing.yearly', 'Anual')}
<span className="text-xs font-bold text-green-600 dark:text-green-400">
{t('billing.save_percent', 'Ahorra 17%')}
</span>
</button>
{/* Billing Cycle Toggle - Only show if not externally controlled */}
{!externalBillingCycle && (
<div className="flex justify-center mb-8">
<div className="inline-flex rounded-lg border-2 border-[var(--border-primary)] p-1 bg-[var(--bg-secondary)]">
<button
onClick={() => {
const newCycle: BillingCycle = 'monthly';
setInternalBillingCycle(newCycle);
if (onBillingCycleChange) {
onBillingCycleChange(newCycle);
}
}}
className={`px-6 py-2 rounded-md text-sm font-semibold transition-all ${
billingCycle === 'monthly'
? 'bg-[var(--color-primary)] text-white shadow-md'
: 'text-[var(--text-secondary)] hover:bg-[var(--bg-primary)] hover:text-[var(--text-primary)]'
}`}
>
{t('billing.monthly', 'Mensual')}
</button>
<button
onClick={() => {
const newCycle: BillingCycle = 'yearly';
setInternalBillingCycle(newCycle);
if (onBillingCycleChange) {
onBillingCycleChange(newCycle);
}
}}
className={`px-6 py-2 rounded-md text-sm font-semibold transition-all flex items-center gap-2 ${
billingCycle === 'yearly'
? 'bg-[var(--color-primary)] text-white shadow-md'
: 'text-[var(--text-secondary)] hover:bg-[var(--bg-primary)] hover:text-[var(--text-primary)]'
}`}
>
{t('billing.yearly', 'Anual')}
<span className="text-xs font-bold text-green-600 dark:text-green-400">
{t('billing.save_percent', 'Ahorra 17%')}
</span>
</button>
</div>
</div>
</div>
)}
{/* Simplified Plans Grid */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8 items-start lg:items-stretch">
@@ -186,7 +208,7 @@ export const SubscriptionPricingCards: React.FC<SubscriptionPricingCardsProps> =
const CardWrapper = mode === 'landing' ? Link : 'div';
const isCurrentPlan = mode === 'settings' && selectedPlan === tier;
const cardProps = mode === 'landing'
? { to: getRegisterUrl(tier) }
? { to: getRegisterUrl(tier, billingCycle) }
: mode === 'selection' || (mode === 'settings' && !isCurrentPlan)
? { onClick: () => handlePlanAction(tier, plan) }
: {};

View File

@@ -73,166 +73,439 @@
"gettingStarted": {
"quickStart": {
"title": "Guía de Inicio Rápido",
"description": "Configura tu cuenta de Panadería IA en solo 10 minutos y empieza a reducir desperdicios",
"readTime": "5",
"description": "Configura tu cuenta de BakeWise en 15-20 minutos con nuestro asistente guiado paso a paso",
"readTime": "8",
"content": {
"intro": "Bienvenido a Panadería IA. Esta guía te ayudará a configurar tu cuenta en 10 minutos para empezar a optimizar tu producción desde el primer día.",
"intro": "Bienvenido a BakeWise (Panadería IA). Nuestro asistente de configuración guiado te ayudará a poner en marcha el sistema en 15-20 minutos. El proceso incluye análisis automático de tus datos con IA para detectar productos y crear tu inventario inicial.",
"steps": [
{
"title": "1. Crea tu Cuenta",
"description": "Regístrate con tu email y contraseña. Te pediremos información básica de tu panadería: nombre, dirección, número de empleados."
"title": "1. Registro de Usuario",
"description": "Crea tu cuenta con email y contraseña. El sistema te enviará un email de verificación. Puedes registrarte también a través de una sesión demo para probar sin compromiso."
},
{
"title": "2. Configura tu Perfil de Panadería",
"description": "Indica el tipo de panadería: artesanal, industrial, obrador central con puntos de venta. Esto ayuda al sistema a personalizar las recomendaciones."
"title": "2. Selección de Tipo de Negocio",
"description": "Indica si eres: Panadería Tradicional (producción y venta en el mismo local), Obrador Central (produces para distribuir a otros puntos), Punto de Venta (recibes producto de un obrador central), o Modelo Mixto (producción propia + distribución). Esto personaliza el flujo de configuración."
},
{
"title": "3. Añade tus Productos",
"description": "Crea tu catálogo: pan, bollería, pasteles. Para cada producto indica nombre, precio de venta y categoría."
"title": "3. Registro de tu Panadería",
"description": "Completa la información: Nombre del negocio, Dirección completa (con autocompletado de Google Maps), Código postal y ciudad, Teléfono de contacto. El sistema detecta automáticamente tu ubicación y analiza el contexto de puntos de interés cercanos (escuelas, oficinas, estaciones) para mejorar las predicciones."
},
{
"title": "4. Importa Historial de Ventas (Opcional)",
"description": "Cuantos más datos históricos proporciones, más precisas serán las predicciones. Acepta Excel, CSV o importación desde tu TPV."
"title": "4. Subir Datos de Ventas Históricos",
"description": "Sube un archivo Excel o CSV con tu historial de ventas (mínimo 3 meses recomendado). El sistema incluye IA que analiza automáticamente el archivo para: Detectar productos únicos, Identificar categorías (pan, bollería, pastelería), Extraer patrones de ventas. El análisis tarda 30-60 segundos."
},
{
"title": "5. Primera Predicción",
"description": "El sistema generará automáticamente tu primera predicción de demanda para los próximos 7 días basándose en patrones similares."
"title": "5. Revisar Inventario Detectado",
"description": "La IA te muestra todos los productos detectados agrupados por categoría. Puedes: Aprobar productos tal cual, Editar nombres o categorías, Eliminar duplicados, Añadir productos manualmente. El sistema crea automáticamente el inventario completo."
},
{
"title": "6. Configurar Stock Inicial (Opcional)",
"description": "Para cada producto/ingrediente detectado, indica las cantidades actuales en stock. Esto es opcional pero recomendado para empezar con control de inventario desde el día 1."
},
{
"title": "7. Configurar Proveedores (Opcional)",
"description": "Añade tus proveedores principales: nombre, contacto, productos que suministran. Puedes saltarte este paso e ir directamente al entrenamiento del modelo IA."
},
{
"title": "8. Entrenamiento del Modelo IA",
"description": "El sistema entrena automáticamente tu modelo personalizado de predicción usando: Tus datos históricos de ventas, Contexto de ubicación (POIs detectados), Calendario de festivos español, Datos meteorológicos de AEMET. El entrenamiento tarda 2-5 minutos y muestra progreso en tiempo real vía WebSocket."
},
{
"title": "9. ¡Listo para Usar!",
"description": "Una vez completado el entrenamiento, accedes al dashboard principal donde verás: Predicciones de demanda para los próximos 7 días, Plan de producción sugerido para hoy, Alertas de stock bajo, Métricas clave del negocio."
}
],
"tips": [
"Empieza con 5-10 productos principales, no necesitas todo el catálogo el primer día",
"Si tienes historial de ventas de los últimos 3-6 meses, súbelo para predicciones más precisas",
"El sistema mejora con el tiempo: las primeras semanas tendrá 15-20% de margen de error, después del primer mes baja a ~10%"
]
"IMPORTANTE: Sube al menos 3-6 meses de historial de ventas para que la IA pueda detectar patrones estacionales",
"El archivo de ventas debe tener columnas: Fecha, Producto, Cantidad. El sistema detecta automáticamente el formato",
"El análisis con IA te ahorra horas de trabajo manual creando el catálogo e inventario automáticamente",
"Puedes saltar pasos opcionales (stock inicial, proveedores) y configurarlos después desde el dashboard",
"La precisión de predicciones mejora con el tiempo: primeras 2 semanas ~70-75%, después del primer mes ~80-85%",
"Usuarios Enterprise pueden registrar múltiples sucursales (puntos de venta) tras crear el obrador central"
],
"conclusion": "El asistente guiado incluye validación en cada paso y guarda tu progreso automáticamente. Puedes pausar en cualquier momento y continuar después desde donde lo dejaste."
}
},
"importData": {
"title": "Importar Datos Históricos de Ventas",
"description": "Aprende a subir tu historial de ventas desde Excel, CSV o tu sistema TPV para mejorar la precisión",
"readTime": "8",
"description": "Sube tu historial de ventas en Excel o CSV con validación automática y análisis IA",
"readTime": "10",
"content": {
"intro": "Cuantos más datos históricos proporciones, más precisas serán las predicciones de demanda. Te recomendamos mínimo 3 meses de historial.",
"formats": [
"intro": "El sistema de importación incluye validación automática inteligente y análisis con IA. Cuantos más datos históricos proporciones (recomendamos 3-12 meses), más precisas serán las predicciones de demanda y el sistema podrá detectar patrones estacionales.",
"supportedFormats": [
{
"name": "Excel (.xlsx)",
"description": "Formato más común. Necesitas columnas: Fecha, Producto, Cantidad Vendida, Precio (opcional)"
"format": "Excel (.xlsx, .xls)",
"description": "Formato más común y recomendado. Soporta múltiples hojas (se usa la primera). Permite formato de fecha flexible."
},
{
"name": "CSV (.csv)",
"description": "Exportable desde cualquier TPV. Mismo formato que Excel pero en texto plano"
"format": "CSV (.csv)",
"description": "Texto plano separado por comas. Exportable desde cualquier TPV o sistema de caja. Codificación UTF-8 recomendada."
},
{
"name": "Exportación TPV",
"description": "Si tu TPV es compatible, puedes exportar directamente el historial"
"format": "JSON",
"description": "Para integraciones avanzadas. Formato estructurado para APIs o exportaciones programáticas."
}
],
"requiredColumns": {
"title": "Columnas Requeridas en tu Archivo",
"columns": [
{
"name": "Fecha",
"description": "Fecha de la venta. Formatos aceptados: DD/MM/AAAA, AAAA-MM-DD, DD-MM-AAAA. Ejemplo: 15/03/2024 o 2024-03-15",
"required": true
},
{
"name": "Producto",
"description": "Nombre del producto vendido. Puede ser cualquier texto. La IA detecta y agrupa productos similares automáticamente.",
"required": true
},
{
"name": "Cantidad",
"description": "Unidades vendidas. Número entero o decimal. Ejemplo: 12 (baguettes) o 2.5 (kg de pan integral)",
"required": true
},
{
"name": "Precio (Opcional)",
"description": "Precio de venta unitario o total. Útil para análisis de ingresos pero no obligatorio para predicciones.",
"required": false
}
]
},
"steps": [
{
"title": "1. Prepara tu archivo",
"description": "Asegúrate de tener: Fecha (formato DD/MM/AAAA), Nombre del Producto, Cantidad Vendida. Precio de venta es opcional."
"title": "1. Durante el Onboarding (Opción Recomendada)",
"description": "El asistente de configuración inicial incluye un paso de 'Subir Datos de Ventas' donde puedes arrastrar y soltar tu archivo. El sistema: 1) Valida el formato automáticamente, 2) Extrae productos únicos, 3) Clasifica con IA en categorías (pan, bollería, pastelería), 4) Crea el inventario completo automáticamente. ¡Esto te ahorra horas de trabajo manual!"
},
{
"title": "2. Ve a Configuración > Importar Datos",
"description": "En el menú principal, busca 'Importar Datos Históricos'"
"title": "2. Desde el Dashboard (Después del Onboarding)",
"description": "Ve a Analítica > Ventas > Importar Datos. Selecciona el rango de fechas y sube tu archivo. El sistema detecta automáticamente productos nuevos y te pregunta si quieres añadirlos al catálogo."
},
{
"title": "3. Selecciona el archivo",
"description": "Arrastra y suelta tu archivo Excel/CSV o haz clic para seleccionarlo"
"title": "3. Validación Automática",
"description": "El sistema valida en tiempo real: Formato de archivo correcto (Excel/CSV), Formato de fechas válido, Columnas requeridas presentes, Datos numéricos en campos de cantidad. Si hay errores, muestra exactamente qué fila y qué problema."
},
{
"title": "4. Mapea las columnas",
"description": "El sistema detecta automáticamente las columnas, verifica que coincidan correctamente"
"title": "4. Análisis con IA (Durante Onboarding)",
"description": "La IA analiza tu archivo y: Detecta productos únicos (agrupa variaciones como 'Baguette', 'baguette', 'BAGUETTE'), Clasifica automáticamente en categorías basándose en nombres comunes de panadería, Identifica si son productos finales o ingredientes, Sugiere unidades de medida apropiadas. Esto tarda 30-60 segundos."
},
{
"title": "5. Confirma e importa",
"description": "Revisa el resumen y confirma. El sistema procesará los datos en segundo plano"
"title": "5. Revisión y Confirmación",
"description": "El sistema muestra: Total de filas procesadas, Productos únicos detectados, Agrupados por categoría (pan: 12 productos, bollería: 8, etc.). Puedes editar, eliminar o añadir productos antes de confirmar."
},
{
"title": "6. Importación Final",
"description": "Al confirmar, el sistema: Crea/actualiza productos en el inventario, Importa todas las ventas históricas a la base de datos, Asocia ventas con productos del catálogo, Prepara datos para entrenamiento del modelo IA. Archivos grandes (>50.000 filas) se procesan en segundo plano."
}
],
"tips": [
"IMPORTANTE: Durante el onboarding, la importación de datos crea automáticamente tu catálogo e inventario completo con IA",
"Si importas después del onboarding, asegúrate de que los nombres de productos coincidan exactamente con los del catálogo",
"Formatos de fecha flexibles: el sistema detecta automáticamente DD/MM/AAAA, AAAA-MM-DD, DD-MM-AAAA",
"Puedes importar múltiples veces. El sistema detecta duplicados por fecha + producto y te pregunta si quieres sobrescribir",
"Archivos >100.000 filas: se procesan en segundo plano, recibirás notificación cuando termine (5-10 min típicamente)",
"Codificación CSV: usa UTF-8 para evitar problemas con acentos y caracteres españoles (ñ, á, é, etc.)"
],
"commonIssues": [
{
"issue": "Error: Formato de fecha inválido",
"solution": "Usa formato DD/MM/AAAA (ejemplo: 15/03/2025)"
"issue": "Error: 'Columna Fecha no encontrada'",
"solution": "Asegúrate de que tu archivo tiene una columna llamada 'Fecha', 'Date', 'Día' o similar. El sistema detecta variaciones comunes pero la columna debe existir."
},
{
"issue": "Productos no reconocidos",
"solution": "Asegúrate de que los nombres coincidan exactamente con los productos creados en tu catálogo"
"issue": "Error: 'Formato de fecha inválido en fila X'",
"solution": "Usa formato DD/MM/AAAA (15/03/2024) o AAAA-MM-DD (2024-03-15). En Excel, formatea la columna como 'Fecha' o 'Texto' (no 'General')."
},
{
"issue": "Importación lenta",
"solution": "Archivos grandes (>100.000 filas) pueden tardar 5-10 minutos. Recibirás un email cuando termine"
"issue": "Productos duplicados después de importar",
"solution": "Normaliza nombres antes de importar: 'Baguette', 'baguette', 'BAGUETTE' se detectan como iguales durante onboarding, pero no en importaciones posteriores."
},
{
"issue": "El archivo se sube pero no muestra progreso",
"solution": "Archivos >10 MB pueden tardar. Espera 30-60 segundos. Si no avanza, verifica que el archivo no esté corrupto (abrelo primero en Excel/LibreOffice)."
},
{
"issue": "Importación completa pero faltan productos en catálogo",
"solution": "En importaciones posteriores al onboarding, productos nuevos requieren confirmación manual. Revisa la notificación 'X productos nuevos detectados' y apruébalos."
}
]
],
"advancedFeatures": [
{
"feature": "Mapeo de Columnas Flexible",
"description": "El sistema detecta automáticamente columnas aunque tengan nombres diferentes: 'Fecha', 'Date', 'Día'; 'Producto', 'Product', 'Item', 'Artículo'; 'Cantidad', 'Qty', 'Unidades', 'Vendido'."
},
{
"feature": "Detección de Duplicados",
"description": "Si importas datos que ya existen (mismo producto + fecha), el sistema te pregunta: Sobrescribir valores existentes, Saltar duplicados, o Cancelar importación."
},
{
"feature": "Validación Pre-Import",
"description": "Antes de importar, puedes validar el archivo. El sistema muestra: Filas válidas vs inválidas, Lista de errores específicos por fila, Productos únicos detectados. No se guarda nada hasta que confirmes."
}
],
"conclusion": "La importación durante el onboarding es mágica: subes un archivo y obtienes catálogo completo + inventario + clasificación IA en menos de 2 minutos. Importaciones posteriores son más manuales pero igualmente validadas automáticamente."
}
},
"productsCatalog": {
"title": "Configurar Catálogo de Productos",
"description": "Crea tu catálogo de productos, recetas e ingredientes para gestión completa de inventario",
"readTime": "6",
"description": "Gestiona productos finales, ingredientes y recetas con creación automática vía IA o manual",
"readTime": "12",
"content": {
"intro": "El catálogo de productos es el corazón del sistema. Aquí defines qué produces, cómo se hace y cuánto cuesta.",
"productLevels": [
{
"level": "Productos Finales",
"description": "Lo que vendes (pan, croissant, tarta). Define nombre, precio, categoría, código de barras (opcional)"
"intro": "El catálogo de productos es el núcleo del sistema. BakeWise ofrece DOS formas de crearlo: AUTOMÁTICA (durante onboarding con IA) o MANUAL (paso a paso desde el dashboard). El inventario unifica productos finales e ingredientes en una sola vista jerárquica.",
"twoApproaches": {
"title": "Dos Formas de Crear tu Catálogo",
"automatic": {
"name": "AUTOMÁTICA - Durante Onboarding (Recomendado)",
"description": "Subes archivo de ventas → IA detecta productos → Clasifica categorías → Crea inventario completo. Tarda 2-3 minutos y te ahorra horas de trabajo manual. Ver tutorial 'Importar Datos Históricos'.",
"pros": ["Rapidísimo (2-3 min total)", "IA clasifica automáticamente", "Detecta duplicados", "Identifica productos vs ingredientes"]
},
{
"level": "Recetas",
"description": "Cómo se hace cada producto. Lista de ingredientes con cantidades exactas, pasos de elaboración, tiempo de producción"
},
{
"level": "Ingredientes",
"description": "Materias primas (harina, azúcar, mantequilla). Define unidad de medida, proveedor, precio por kilo"
"manual": {
"name": "MANUAL - Desde Dashboard",
"description": "Añades productos/ingredientes uno por uno desde Mi Panadería > Inventario. Útil para: Añadir nuevos productos después del onboarding, Corregir clasificaciones de IA, Catálogos pequeños (<20 productos).",
"pros": ["Control total sobre categorización", "Útil para añadir productos nuevos", "No requiere archivo de ventas"]
}
],
},
"inventoryStructure": {
"title": "Estructura del Inventario (Unificado)",
"description": "BakeWise usa un inventario UNIFICADO que incluye tanto productos finales como ingredientes en la misma tabla. Cada ítem tiene:",
"fields": [
{
"field": "Tipo de Producto",
"values": "Producto Final (lo que vendes) o Ingrediente (materia prima)",
"example": "Baguette = Producto Final, Harina T-55 = Ingrediente"
},
{
"field": "Categoría",
"values": "Pan, Bollería, Pastelería, Especiales, Otros (para productos finales). Harinas, Lácteos, Levaduras, etc. (para ingredientes)",
"example": "Croissant → Bollería, Mantequilla → Lácteos"
},
{
"field": "Unidad de Medida",
"values": "unidades, kg, g, L, ml, docena",
"example": "Baguette = unidades, Harina = kg, Leche = L"
},
{
"field": "Stock Actual",
"values": "Cantidad disponible ahora (se actualiza automáticamente con entradas/salidas)",
"example": "Baguettes: 45 unidades, Harina: 120 kg"
},
{
"field": "Punto de Reorden",
"values": "Stock mínimo que dispara alerta de compra",
"example": "Harina: 50 kg (si baja de 50, alerta automática)"
},
{
"field": "Proveedor Principal",
"values": "Quién te suministra (solo para ingredientes)",
"example": "Harina T-55 → Harinera La Espiga S.A."
},
{
"field": "Precio",
"values": "Coste unitario (ingredientes) o precio de venta (productos finales)",
"example": "Harina: 0.85€/kg, Baguette: 1.20€/unidad"
}
]
},
"steps": [
{
"title": "1. Añadir Productos",
"description": "Ve a Productos > Nuevo Producto. Completa: Nombre, Categoría (pan, bollería, pastelería), Precio de venta, Peso/unidades por pieza"
"title": "1. Acceder al Inventario Unificado",
"description": "Ve a Mi Panadería > Inventario en el menú lateral. Aquí ves TODOS los ítems: productos finales e ingredientes juntos. Usa las pestañas 'Productos Finales' e 'Ingredientes' para filtrar, o la vista 'Todos' para verlo completo."
},
{
"title": "2. Crear Recetas",
"description": "Para cada producto, crea su receta. Añade ingredientes con cantidades exactas (ej: Harina 500g, Agua 300ml). Indica tiempo de amasado, fermentación y horneado"
"title": "2. Añadir Producto Final (Manual)",
"description": "Click en '+ Nuevo Producto' → Selecciona tipo 'Producto Final' → Completa: Nombre (ej: Croissant de Mantequilla), Categoría (Bollería), Unidad (unidades), Precio de venta (ej: 1.50€), Stock inicial (opcional), Código de barras/SKU (opcional). El sistema crea el producto y lo añade al inventario."
},
{
"title": "3. Gestionar Ingredientes",
"description": "Ve a Inventario > Ingredientes. Añade todas tus materias primas con: Nombre, Unidad (kg, L, unidades), Proveedor, Precio por unidad, Stock mínimo (para alertas)"
"title": "3. Añadir Ingrediente (Manual)",
"description": "Click en '+ Nuevo Ingrediente' → Completa: Nombre (ej: Harina de Fuerza T-65), Categoría (Harinas), Unidad (kg), Proveedor (selecciona de lista o añade nuevo), Precio por unidad (ej: 0.92€/kg), Stock inicial (ej: 150 kg), Punto de reorden (ej: 50 kg). Cuando stock baje de 50 kg, recibirás alerta automática."
},
{
"title": "4. Crear Recetas (Producción)",
"description": "Ve a Mi Panadería > Recetas → '+ Nueva Receta' → Selecciona producto final (ej: Croissant) → Añade ingredientes con cantidades: Harina T-65: 500g, Mantequilla: 250g, Leche: 150ml, etc. → Indica rendimiento (cuántas unidades salen), tiempo de producción, pasos. Las recetas permiten: Calcular coste de producción automáticamente, Saber cuánto ingrediente necesitas para X unidades, Planificar compras basándose en producción prevista."
},
{
"title": "5. Gestionar Proveedores",
"description": "Ve a Mi Panadería > Proveedores → '+ Nuevo Proveedor' → Completa: Nombre empresa, Contacto (email, teléfono), Dirección, Productos que suministra, Días de entrega, Monto mínimo de pedido. Luego asigna proveedores a ingredientes para: Generar órdenes de compra automáticas, Comparar precios entre proveedores, Rastrear rendimiento (entregas a tiempo, calidad)."
},
{
"title": "6. Configurar Alertas de Stock",
"description": "Para cada ingrediente, define 'Punto de Reorden'. Cuando stock actual < punto de reorden, recibes: Alerta en dashboard (ícono rojo), Notificación por email/WhatsApp (si configurado), Sugerencia automática de orden de compra. Ejemplo: Harina con punto de reorden 50 kg → al llegar a 49 kg, alerta 'Hacer pedido de harina'."
}
],
"recipes": {
"title": "Sistema de Recetas (Opcional pero Recomendado)",
"description": "Las recetas conectan productos finales con ingredientes. Beneficios clave:",
"benefits": [
"Cálculo automático de coste de producción por producto",
"Planificación de compras: 'Para producir 200 baguettes necesito 100 kg harina'",
"Consumo automático de stock al registrar producciones (FIFO)",
"Análisis de rentabilidad: margen = precio venta - coste ingredientes",
"Escalado de lotes: receta para 10 unidades → sistema calcula para 100"
],
"recipeFields": [
"Producto final que produce",
"Lista de ingredientes con cantidades exactas",
"Rendimiento (cuántas unidades salen de esta receta)",
"Tiempo de producción (preparación + horneado + enfriado)",
"Pasos/instrucciones (opcional, para capacitación de equipo)",
"Temperatura y equipo necesario (horno, batidora, etc.)"
]
},
"tips": [
"Empieza con productos de alta rotación (los que más vendes)",
"Las recetas permiten calcular automáticamente cuánto ingrediente necesitas para la producción diaria",
"El sistema detecta automáticamente cuándo un ingrediente está por acabarse y sugiere hacer un pedido"
]
"CLAVE: Durante onboarding, usa importación IA para crear catálogo en 2 minutos. Luego refina manualmente si es necesario",
"Estructura jerárquica: Categorías > Productos/Ingredientes. Usa categorías consistentes para mejores reportes",
"Punto de reorden = (Consumo diario promedio × Días de entrega del proveedor) + Margen de seguridad 20%",
"Recetas son OPCIONALES para predicciones, pero ESENCIALES para: planificación de compras, control de costes, producción automatizada",
"El sistema soporta recetas multi-nivel: Croissant usa Masa Madre, y Masa Madre tiene su propia receta de ingredientes",
"Puedes importar catálogo desde Excel: Plantilla disponible en Inventario > Importar > Descargar Plantilla"
],
"advancedFeatures": [
{
"feature": "Gestión de Lotes y Caducidades",
"description": "Para ingredientes perecederos, registra lote y fecha de caducidad en cada entrada. Sistema usa FIFO automático (First-In-First-Out) y alerta 7 días antes de caducar."
},
{
"feature": "Códigos de Barras / SKU",
"description": "Asigna códigos de barras a productos/ingredientes. Útil para: Escaneo rápido en recepción de pedidos, Integración con TPV, Trazabilidad HACCP."
},
{
"feature": "Variantes de Producto",
"description": "Crea variantes (ej: Baguette Normal, Baguette Integral, Baguette Sin Sal) que comparten receta base pero con diferencias. Sistema predice demanda por variante."
},
{
"feature": "Imágenes de Productos",
"description": "Sube fotos de productos finales. Útil para: Capacitación de equipo (cómo debe verse), Control de calidad visual, Catálogo para clientes."
}
],
"conclusion": "La forma más eficiente es: 1) Usar IA durante onboarding para crear inventario base (2 min), 2) Añadir recetas manualmente para productos principales (15-30 min), 3) Ir añadiendo nuevos productos/ingredientes según necesites. El inventario unificado simplifica la gestión vs. tener productos e ingredientes separados."
}
},
"firstPrediction": {
"title": "Tu Primera Predicción de Demanda",
"description": "Entiende cómo interpreta el sistema y cómo ajustar las predicciones según tu experiencia",
"readTime": "10",
"description": "Cómo funciona el sistema de predicción con Prophet, qué métricas ver y cómo interpretar resultados",
"readTime": "12",
"content": {
"intro": "Las predicciones de demanda son el núcleo de Panadería IA. Usan inteligencia artificial para predecir cuánto venderás de cada producto.",
"howItWorks": "El algoritmo analiza: Historial de ventas (últimos 3-12 meses), Día de la semana y estacionalidad, Festivos y eventos especiales, Clima (temperatura, lluvia), Tendencias recientes",
"readingPredictions": [
{
"metric": "Demanda Prevista",
"description": "Cuántas unidades predice el sistema que venderás. Ejemplo: '150 baguettes para el Viernes 15/11'"
},
{
"metric": "Rango de Confianza",
"description": "El mínimo y máximo esperado. Ejemplo: '130-170 baguettes' (95% de confianza). Útil para planificar conservador o agresivo"
},
{
"metric": "Comparación vs Promedio",
"description": "'+15%' significa que se espera 15% más de lo habitual para ese día. Ayuda a detectar picos de demanda"
}
],
"adjustments": "Puedes ajustar manualmente las predicciones si tienes información que el sistema no conoce (ej: evento local, promoción). El sistema aprende de tus ajustes.",
"intro": "Las predicciones de demanda son el corazón de BakeWise. Utilizan Prophet (algoritmo de Facebook optimizado para series temporales) más datos contextuales de España (festivos, clima AEMET, puntos de interés cercanos) para predecir cuánto venderás de cada producto en los próximos 7-30 días.",
"whenFirstPrediction": {
"title": "¿Cuándo se Genera la Primera Predicción?",
"description": "Tu primera predicción se genera AUTOMÁTICAMENTE al completar el paso 'Entrenamiento del Modelo IA' durante el onboarding. Este proceso:",
"steps": [
"Toma tus datos históricos de ventas (mínimo 3 meses)",
"Detecta patrones: tendencia general, estacionalidad semanal/anual, efectos de festivos",
"Integra contexto de ubicación (POIs: escuelas, oficinas, estaciones cerca)",
"Consulta calendario de festivos español (nacionales y locales de Madrid)",
"Entrena modelo personalizado por producto (2-5 minutos vía WebSocket)",
"Genera predicciones para los próximos 7 días automáticamente"
],
"timing": "Después del onboarding, el sistema genera predicciones DIARIAMENTE a las 5:30 AM de forma automática. No necesitas hacer nada manual."
},
"howProphetWorks": {
"title": "Cómo Funciona Prophet (Simplificado)",
"description": "Prophet descompone tus ventas históricas en componentes:",
"components": [
{
"component": "Tendencia (Trend)",
"description": "¿Están subiendo o bajando las ventas con el tiempo? Ej: crecimiento 5% mensual desde apertura",
"example": "Si vendes más cada mes, Prophet detecta esa curva ascendente"
},
{
"component": "Estacionalidad Semanal",
"description": "Patrones que se repiten cada semana. Ej: Sábados vendes 50% más que Lunes",
"example": "Lun: 80 baguettes, Sáb: 120 baguettes (patrón detectado automáticamente)"
},
{
"component": "Estacionalidad Anual",
"description": "Patrones anuales. Ej: Diciembre (Navidad) vendes 200% más roscones",
"example": "Requiere mínimo 12 meses de datos para detectar"
},
{
"component": "Efectos de Festivos",
"description": "Impacto de festivos españoles: Reyes, Semana Santa, Navidad, etc. Prophet sabe que 6 de Enero (Reyes) dispara ventas de roscón",
"example": "Sistema incluye calendario completo de festivos nacionales y Madrid"
},
{
"component": "Regresores Externos (BakeWise)",
"description": "Variables adicionales que BakeWise añade: Clima (temperatura, lluvia de AEMET), Tráfico (datos de Madrid), POIs (cuántas escuelas/oficinas hay cerca)",
"example": "Días de lluvia → -15% ventas de ciertos productos (detectado automáticamente)"
}
]
},
"readingPredictions": {
"title": "Cómo Leer tus Predicciones (Dashboard)",
"description": "Ve a Analítica > Predicciones. Para cada producto verás:",
"metrics": [
{
"metric": "yhat (Predicción Central)",
"description": "Valor más probable de ventas. Esto es lo que el sistema 'espera' que vendas.",
"example": "Baguette - Viernes 17/01: yhat = 145 unidades",
"interpretation": "Planifica producir ~145 baguettes para ese día"
},
{
"metric": "yhat_lower (Mínimo Esperado)",
"description": "Límite inferior del intervalo de confianza al 95%. Hay 95% probabilidad de vender MÁS que esto.",
"example": "yhat_lower = 125 unidades",
"interpretation": "Escenario conservador: produce mínimo 125 para cubrir demanda base"
},
{
"metric": "yhat_upper (Máximo Esperado)",
"description": "Límite superior del intervalo de confianza al 95%. Hay 95% probabilidad de vender MENOS que esto.",
"example": "yhat_upper = 165 unidades",
"interpretation": "Escenario optimista: si produces 165, probablemente te sobre algo"
},
{
"metric": "Comparación vs Promedio",
"description": "Porcentaje vs. promedio histórico de ese día de la semana.",
"example": "+12% vs promedio Viernes",
"interpretation": "Se espera un Viernes mejor de lo habitual (quizás festivo cercano)"
},
{
"metric": "Precisión Histórica (MAPE)",
"description": "Qué tan acertadas han sido predicciones pasadas para este producto. MAPE = Mean Absolute Percentage Error.",
"example": "MAPE = 15% significa que el error promedio es 15%",
"interpretation": "MAPE <20% = bueno, <15% = excelente, >25% = revisar datos o modelo"
}
]
},
"visualizations": {
"title": "Gráficos Disponibles",
"charts": [
"Gráfico de Línea: predicción (yhat) + intervalo de confianza (zona sombreada)",
"Comparativa vs Real: línea azul = predicción, puntos naranjas = ventas reales (para validar precisión)",
"Componentes de Prophet: gráfico de tendencia, estacionalidad semanal, efectos festivos por separado",
"Heatmap Semanal: qué días/horas vendes más (si tienes datos horarios)"
]
},
"adjustingPredictions": {
"title": "Ajustar Predicciones Manualmente",
"description": "Si conoces información que Prophet no tiene (evento local, promoción, obra en la calle), puedes ajustar:",
"howTo": [
"Ve a predicción de producto específico → Click en día futuro",
"Selecciona 'Ajustar Manualmente'",
"Indica nuevo valor (ej: aumentar 20% por feria local)",
"Añade nota explicativa (ej: 'Feria del barrio este fin de semana')",
"Sistema guarda ajuste y APRENDE: si feria se repite cada año, Prophet lo detectará"
],
"learningNote": "El sistema valida predicciones vs ventas reales cada noche. Si tus ajustes manuales mejoran precisión, Prophet ajusta automáticamente sus parámetros."
},
"tips": [
"Los primeros 7-14 días las predicciones pueden tener 15-20% de error mientras el sistema aprende tus patrones",
"Después del primer mes, la precisión típica es 85-90%",
"Si una predicción parece muy alta o baja, revisa si hay un festivo o evento que explique el cambio",
"Ajusta cuando sepas algo que la IA no sabe (ej: reforma en tu calle, feria local)"
]
"PRIMERA SEMANA: Predicciones pueden tener 15-20% error (MAPE). Es normal, el modelo está aprendiendo",
"PRIMER MES: Precisión mejora a ~10-15% MAPE conforme valida predicciones vs ventas reales diarias",
"DESPUÉS DE 3 MESES: Precisión estabiliza en 8-12% MAPE (85-90% precisión) para productos con datos suficientes",
"Productos con POCA rotación (vendes <5 unidades/día) tendrán mayor error que productos de ALTA rotación",
"Si MAPE >25% después de 1 mes: revisa datos (¿duplicados? ¿productos mal nombrados?) o contacta soporte",
"Intervalo de confianza AMPLIO (yhat_upper - yhat_lower > 50% del yhat) = alta incertidumbre, necesitas más datos",
"Festivos ATÍPICOS (no oficiales): añádelos manualmente en Configuración > Festivos Personalizados para mejor precisión"
],
"automaticRetraining": {
"title": "Reentrenamiento Automático del Modelo",
"description": "BakeWise re-entrena modelos automáticamente cuando:",
"triggers": [
"MAPE sube >30% por 7 días consecutivos (señal de cambio de patrón)",
"Cada 30 días (actualización programada para incorporar datos nuevos)",
"Después de importar lote grande de datos históricos nuevos",
"Cuando añades nuevos festivos personalizados o cambias ubicación"
],
"process": "Reentrenamiento tarda 2-5 minutos, se hace en segundo plano (5:30 AM típicamente). Recibes notificación cuando termina con nuevo MAPE."
},
"conclusion": "Tu primera predicción aparece automáticamente tras el onboarding. Usa yhat como guía principal, yhat_lower/upper para planificar escenarios. La precisión mejora dramáticamente en las primeras 2-4 semanas conforme el modelo valida y aprende de tus ventas reales. No te preocupes si los primeros días el error es alto, es completamente normal."
}
}
},
@@ -240,98 +513,414 @@
"demandForecasting": {
"title": "Predicción de Demanda con IA",
"description": "Cómo funciona el algoritmo Prophet y cómo optimizar la precisión de las predicciones",
"readTime": "12",
"readTime": "18",
"content": {
"intro": "La predicción de demanda usa Prophet, un algoritmo de inteligencia artificial desarrollado por Facebook, optimizado para panaderías españolas.",
"intro": "La predicción de demanda usa Prophet, un algoritmo de inteligencia artificial desarrollado por Facebook (Meta), específicamente optimizado para series temporales con patrones estacionales fuertes. BakeWise lo ha adaptado para panaderías españolas integrando datos externos (clima AEMET, POIs, calendario escolar) y reglas de negocio específicas del sector.",
"algorithm": {
"title": "Cómo Funciona el Algoritmo",
"description": "Prophet analiza patrones en tus datos históricos para hacer predicciones precisas. Identifica: Tendencias a largo plazo (¿estás creciendo o bajando ventas?), Estacionalidad diaria (lunes vs viernes), semanal y anual, Efectos de festivos (Navidad, Semana Santa, Reyes), Impacto del clima (lluvia reduce ventas de ciertos productos)"
"title": "Cómo Funciona el Algoritmo Prophet",
"description": "Prophet descompone las ventas en componentes matemáticos independientes que se suman para generar la predicción final:",
"components": [
{
"component": "Tendencia (Trend)",
"description": "Crecimiento o decrecimiento a largo plazo de tus ventas. ¿Estás ganando o perdiendo clientes? Prophet detecta cambios de tendencia (changepoints) automáticamente."
},
{
"component": "Estacionalidad Semanal (Weekly Seasonality)",
"description": "Patrón que se repite cada semana. Ejemplo: lunes 20% menos ventas, viernes-sábado +30%. Prophet aprende cuánto vende cada producto cada día de la semana."
},
{
"component": "Estacionalidad Anual (Yearly Seasonality)",
"description": "Patrones que se repiten cada año: verano +15% (turismo), enero -10% (post-Navidad), septiembre +20% (vuelta al cole). Requiere al menos 1 año de datos históricos."
},
{
"component": "Efectos de Festivos (Holidays)",
"description": "Impacto de días especiales: Navidad +50%, Reyes +35%, Semana Santa +25%, festivos locales. El sistema incluye calendario completo español + autonómico + local (si detecta tu ciudad)."
},
{
"component": "Regresores Externos (External Regressors)",
"description": "Variables externas que afectan ventas: Clima (temperatura, lluvia, viento), POIs cercanos (metro, colegios, oficinas), Tráfico (solo Madrid), Calendario escolar (vacaciones). Con estos datos, el modelo pasa de 10 features básicas a 60+ features mejoradas."
}
],
"formula": "yhat = tendencia + estacionalidad_semanal + estacionalidad_anual + festivos + regresores_externos + ruido"
},
"technicalDetails": {
"title": "Detalles Técnicos del Sistema",
"implementation": [
{
"aspect": "Automatización Diaria",
"description": "Cada día a las 5:30 AM (hora servidor UTC+1), el sistema ejecuta automáticamente: 1) Fetch de nuevas ventas del día anterior, 2) Actualización de datos externos (clima AEMET, tráfico Madrid), 3) Generación de predicciones para próximos 7-30 días, 4) Cálculo de métricas de precisión (MAPE, RMSE, MAE), 5) Notificación si precisión baja del umbral aceptable. Proceso completo: 3-5 minutos para todo el catálogo."
},
{
"aspect": "Tiempos de Respuesta",
"description": "Predicción individual: 500-1000ms (incluye fetch de datos externos + inferencia). Predicción multi-día: ~200ms por día adicional. Batch completo (todos los productos × 7 días): 2-3 minutos. Cache en Redis con TTL de 24 horas: después de primera consulta, respuesta <50ms."
},
{
"aspect": "Intervalos de Confianza",
"description": "Prophet genera 3 valores para cada predicción: yhat_lower (límite inferior, percentil 2.5%), yhat (valor esperado, mediana), yhat_upper (límite superior, percentil 97.5%). Ejemplo: Baguettes mañana → yhat_lower: 95, yhat: 120, yhat_upper: 145. Interpretación: 95% de probabilidad de vender entre 95-145 unidades, valor más probable 120. En la UI (ForecastTable/DemandChart) se muestra como rango con banda sombreada."
},
{
"aspect": "Optimización y Hiperparámetros",
"description": "El modelo base usa: changepoint_prior_scale=0.05 (flexibilidad para detectar cambios de tendencia), seasonality_prior_scale=10 (peso alto a estacionalidad, crítico en panaderías), seasonality_mode='multiplicative' (estacionalidad proporcional a nivel de ventas), interval_width=0.95 (intervalos de confianza 95%). Estos valores se ajustan automáticamente durante reentrenamiento si MAPE no mejora."
}
]
},
"features": [
{
"name": "Predicciones Multi-Día",
"description": "Genera predicciones hasta 30 días en adelante. Útil para planificar compras de ingredientes y vacaciones del personal"
"name": "Predicciones Multi-Día y Multi-Producto",
"description": "Genera predicciones hasta 30 días en adelante para todo tu catálogo. Útil para: Planificar compras de ingredientes con lead time largo, Organizar vacaciones del personal, Anticipar picos de demanda (eventos, festivos). Puedes consultar predicciones por: Producto individual, Categoría completa (todo el pan, toda la bollería), Día específico o rango de fechas. Endpoint: GET /api/v1/forecasting/tenants/{tenant_id}/predictions?product_id=X&start_date=Y&end_date=Z"
},
{
"name": "Intervalos de Confianza",
"description": "Cada predicción incluye mínimo, esperado y máximo (95% de confianza). Si dice '100-150 unidades', hay 95% de probabilidad de vender entre 100-150"
"name": "Integración con Clima AEMET",
"description": "El sistema se conecta diariamente a la API de AEMET (Agencia Española de Meteorología) para obtener: Temperatura máxima/mínima, Probabilidad de precipitación, Velocidad del viento, Nivel de nubosidad. Impacto observado (reglas de negocio): Lluvia > 70% probabilidad → -30% ventas productos 'paseo' (croissants, napolitanas), Temperatura < 10°C → +15% pan tradicional, +20% productos de chocolate, Temperatura > 30°C → -10% bollería pesada, +25% productos ligeros. El sistema aprende qué productos TU vendes más/menos con cada patrón climático."
},
{
"name": "Ajuste por Festivos",
"description": "El sistema conoce todos los festivos nacionales y locales de Madrid. Ajusta automáticamente para Navidad, Reyes, Semana Santa"
"name": "Detección de Puntos de Interés (POI)",
"description": "Durante onboarding, el sistema detecta automáticamente POIs en radio de 500m alrededor de tu panadería usando Nominatim (OpenStreetMap): Estaciones de metro/tren (tráfico peatonal alto), Colegios/institutos (pico matutino + merienda, vacaciones -40%), Oficinas/polígonos industriales (almuerzo corporativo), Hospitales (24/7 estable), Zonas turísticas (verano +50%, invierno -20%). Estos POIs se convierten en features para el modelo: 'cerca_colegio' → ajuste +20% septiembre-junio, -40% julio-agosto."
},
{
"name": "Integración con Clima",
"description": "Consulta la previsión meteorológica de AEMET (Agencia Española de Meteorología). Los días de lluvia suelen tener -20% ventas de algunos productos"
"name": "Calendario de Festivos Multi-Nivel",
"description": "El sistema incluye 3 capas de festivos: Nacional (15 festivos: Año Nuevo, Reyes, Semana Santa, Navidad...), Autonómico (2-4 festivos según comunidad), Local (1-2 festivos patronales de tu ciudad). Detecta automáticamente tu ubicación durante onboarding para aplicar el calendario correcto. Ajustes típicos: Festivo nacional → -50% (cerrado o media jornada), Día previo a festivo → +35% (compras anticipadas), Navidad (24-25 dic) → +80% productos especiales (roscón, turrones)."
},
{
"name": "Ajustes Manuales con Aprendizaje",
"description": "Si conoces eventos locales que el sistema no sabe (feria del pueblo, concierto cercano, obras en la calle), puedes ajustar manualmente la predicción en la UI (ForecastTable → columna Acciones → 'Ajustar'). El sistema registra tu ajuste y la venta real resultante. En próximos eventos similares, usa estos ajustes para mejorar. Ejemplo: Ajustaste +50% por feria local → resultado real fue +55% → próximo año, el sistema ya sugiere +50% automáticamente para esas fechas."
}
],
"uiComponents": {
"title": "Interfaz de Usuario (Frontend)",
"components": [
{
"component": "ForecastTable (Tabla de Predicciones)",
"path": "/dashboard/forecasting",
"description": "Tabla principal con todas las predicciones. Columnas: Producto, Fecha, Predicción (yhat), Min-Max (yhat_lower - yhat_upper), Precisión (MAPE %), Última Actualización. Features: Filtro por producto/categoría, Ordenar por cualquier columna, Búsqueda en tiempo real, Acciones rápidas (Ajustar, Ver Histórico, Ver Detalles). Paginación: 20 filas por página, lazy loading para catálogos grandes (500+ productos)."
},
{
"component": "DemandChart (Gráfico de Demanda)",
"path": "/dashboard/forecasting/chart",
"description": "Visualización con Chart.js. Muestra: Línea azul = predicción (yhat), Banda azul sombreada = intervalo de confianza (yhat_lower a yhat_upper), Puntos verdes = ventas reales históricas, Líneas verticales rojas = festivos. Interactivo: Hover muestra detalles, Click en punto abre modal con breakdown de componentes Prophet, Zoom temporal (7 días, 14 días, 30 días, 3 meses). Exportable a PNG/PDF."
},
{
"component": "Metrics Dashboard (Panel de Métricas)",
"path": "/dashboard/forecasting/metrics",
"description": "KPIs de precisión del sistema: MAPE global (todos los productos), MAPE por categoría (Pan: 12%, Bollería: 18%, Pastelería: 22%), MAPE por producto (top 10 mejores y peores), Trend de precisión (últimos 30 días). Color-coded: Verde <15% (excelente), Amarillo 15-25% (bueno), Rojo >25% (necesita atención)."
}
]
},
"optimization": [
{
"tip": "Datos Históricos",
"description": "Cuantos más meses de historial, mejor. Mínimo 3 meses, ideal 12+ meses"
"tip": "Cantidad de Datos Históricos",
"description": "Mínimo absoluto: 3 meses (detecta estacionalidad semanal). Recomendado: 6-12 meses (detecta estacionalidad anual + festivos). Ideal: 18-24 meses (aprende eventos atípicos, crisis, cambios de tendencia). Con 3 meses: MAPE inicial ~25-30%. Con 12 meses: MAPE inicial ~15-20%. Mejora continua: cada mes que pasa, el modelo re-entrena con más datos y mejora ~1-2% MAPE."
},
{
"tip": "Actualización Continua",
"description": "El sistema valida predicciones vs ventas reales cada noche y re-entrena modelos si la precisión baja"
"tip": "Re-entrenamiento Automático",
"description": "El sistema valida predicciones vs ventas reales cada noche. Si detecta degradación de precisión, activa re-entrenamiento automático. Triggers de re-entrenamiento: MAPE > 30% durante 7 días consecutivos (precisión inaceptable), Modelo antiguo > 30 días sin re-entrenar (datos obsoletos), Cambio estructural detectado (nueva tendencia, nuevo producto), Usuario solicita manualmente (Dashboard → Configuración → 'Forzar Re-entrenamiento'). Proceso de re-entrenamiento: 5-10 minutos, sin downtime (modelo antiguo sigue sirviendo durante entrenamiento), notificación por email cuando completa."
},
{
"tip": "Ajustes Manuales",
"description": "Si conoces un evento local (feria, concierto cerca), ajusta la predicción. El sistema aprende de tus correcciones"
"tip": "Ajuste de Hiperparámetros por Producto",
"description": "Productos con alta variabilidad (pastelería especial, productos estacionales) usan changepoint_prior_scale=0.08 (más flexible). Productos estables (baguette, pan de molde) usan changepoint_prior_scale=0.03 (menos flexible, menos ruido). El sistema clasifica automáticamente cada producto analizando su coeficiente de variación (CV = desviación estándar / media). CV < 0.3 → estable, CV > 0.5 → altamente variable."
},
{
"tip": "Corrección de Outliers y Datos Anómalos",
"description": "El sistema detecta y filtra outliers antes de entrenar: Ventas = 0 en día laboral sin motivo (error de registro) → descartado. Ventas > 3× desviación estándar (pico anómalo: evento único, boda grande) → limitado a percentil 95. Días con festivo no registrado → marcado manualmente y re-etiquetado. Puedes revisar y validar outliers en: Dashboard → Forecasting → Data Quality → Outliers Detectados."
}
],
"metrics": {
"title": "Métricas de Precisión",
"description": "El sistema mide su propia precisión con MAPE (Error Porcentual Absoluto Medio). Objetivo: MAPE < 20% (80%+ precisión). Dashboard muestra precisión por producto y tendencias"
}
"title": "Métricas de Precisión y Validación",
"description": "El sistema usa 3 métricas estándar de ML para medir precisión de predicciones:",
"metricsDetail": [
{
"metric": "MAPE (Mean Absolute Percentage Error)",
"formula": "MAPE = (1/n) × Σ|Real - Predicción| / Real × 100",
"interpretation": "Error porcentual promedio. Métrica principal usada en el sistema. Umbrales: <10% = Excelente (oro), 10-15% = Muy Bueno (verde), 15-25% = Aceptable (amarillo), 25-35% = Mejorable (naranja), >35% = Insuficiente (rojo, requiere intervención). Ejemplo: MAPE 12% → en promedio, predicción difiere ±12% del valor real."
},
{
"metric": "RMSE (Root Mean Squared Error)",
"formula": "RMSE = √[(1/n) × Σ(Real - Predicción)²]",
"interpretation": "Error promedio en unidades absolutas. Penaliza errores grandes más que MAPE. Ejemplo: RMSE = 15 unidades → en promedio, la predicción difiere ±15 unidades del valor real. Útil para entender magnitud del error en tu contexto específico."
},
{
"metric": "MAE (Mean Absolute Error)",
"formula": "MAE = (1/n) × Σ|Real - Predicción|",
"interpretation": "Error absoluto promedio, similar a RMSE pero sin penalización extra a errores grandes. Más robusto a outliers. Útil para comparar precisión entre productos con volúmenes muy diferentes."
}
],
"dashboardLocation": "Ve a Dashboard → Forecasting → Metrics para ver: MAPE por producto (tabla sorteable), MAPE por categoría (gráfico de barras), Evolución temporal de MAPE (gráfico de línea últimos 30 días), Distribución de errores (histograma: ¿errores simétricos o sesgados?), Productos con peor precisión (top 10 que necesitan atención)."
},
"troubleshooting": [
{
"problem": "MAPE > 35% (predicciones muy imprecisas)",
"solutions": [
"Revisa calidad de datos: ¿hay ventas registradas correctamente cada día? ¿outliers sin marcar?",
"Verifica que tienes al menos 3 meses de historial. Con menos, la precisión será mala",
"Comprueba si hubo cambios de negocio recientes (nuevo producto, renovación de local, cambio de horario) que el modelo no sabe",
"Fuerza re-entrenamiento manual en Dashboard → Forecasting → Configuración",
"Si el problema persiste 14+ días, contacta soporte con detalles del producto afectado"
]
},
{
"problem": "Predicciones sistemáticamente altas (sobrestima ventas)",
"solutions": [
"Revisa si hay tendencia decreciente en tus ventas que el modelo no ha capturado todavía (tarda ~2 semanas en detectar nuevas tendencias)",
"Verifica si cambió algo en tu negocio: competencia nueva, obras en la calle, cambio de proveedor que afecta calidad",
"Ajusta manualmente a la baja durante 1-2 semanas. El sistema aprenderá y corregirá automáticamente",
"Revisa configuración de buffer en Producción → Configuración (podría estar añadiendo % extra innecesario)"
]
},
{
"problem": "Predicciones no reflejan festivos correctamente",
"solutions": [
"Verifica que tu ubicación está correcta (Dashboard → Mi Panadería → Datos del Negocio). Si está mal, festivos locales no se aplican",
"Revisa calendario de festivos personalizados (Dashboard → Configuración → Festivos). Añade patronales locales si faltan",
"Algunos festivos dependen del año (Semana Santa cambia fechas). El sistema actualiza calendario automáticamente cada enero, pero confirma que está actualizado"
]
}
],
"advancedFeatures": [
{
"feature": "Predicciones Condicionales (Escenarios What-If)",
"description": "Próximamente: podrás simular escenarios hipotéticos. '¿Qué pasa si llueve mañana?' '¿Y si bajo el precio 10%?' '¿Y si hago promoción en Instagram?' El sistema generará predicciones alternativas para cada escenario."
},
{
"feature": "Aprendizaje Federado Multi-Tenant (Roadmap)",
"description": "Futuro: el sistema aprenderá de patrones agregados de todas las panaderías (anónimamente, GDPR-compliant). Si 100 panaderías en Madrid venden +30% los viernes lluviosos de octubre, tu modelo también aprenderá ese patrón incluso sin tener muchos datos propios de esa condición."
}
],
"tips": [
"CLAVE: La precisión mejora exponencialmente con el tiempo. Primeras 2 semanas: ~65-70% precisión. Primer mes: ~75-80%. Después de 3 meses: ~85-90%. Después de 1 año: ~90-95% (mejor que humanos en promedio).",
"No persigas 100% precisión: es imposible. Las ventas tienen componente aleatorio inevitable (cliente cancela pedido grande, evento imprevisto). MAPE 10-15% es excelente en la industria.",
"Usa predicciones como GUÍA, no como LEY absoluta. Combina IA + tu experiencia para mejores resultados. Si intuyes que mañana venderás más, ajusta al alza.",
"Re-entrenamiento automático suele ejecutar de madrugada (3-4 AM) para no interferir con operaciones. Recibirás email cuando complete.",
"Intervalos de confianza (yhat_lower - yhat_upper) son tu amigo: si el rango es muy amplio (ej: 50-200), significa alta incertidumbre. Produce para el yhat (valor esperado) pero ten ingredientes extra por si acaso.",
"Para productos nuevos sin historial, el sistema usa predicciones de productos similares (misma categoría, similar precio) como punto de partida. Precisión inicial será baja (~30-40% MAPE) pero mejora rápidamente tras 2-3 semanas de ventas reales."
],
"conclusion": "El sistema de predicción de demanda es el corazón de BakeWise. Todas las demás funcionalidades (producción, inventario, compras) dependen de predicciones precisas. Invierte tiempo en: 1) Subir máximo historial posible (12+ meses ideal), 2) Registrar ventas diariamente sin fallos, 3) Marcar eventos/festivos especiales, 4) Revisar métricas semanalmente y actuar si MAPE sube. Con datos limpios y consistentes, el sistema alcanza 85-92% precisión en 90 días, reduciendo desperdicio 40-60% y aumentando ventas 15-25% (menos roturas de stock)."
}
},
"productionPlanning": {
"title": "Planificación de Producción Automatizada",
"description": "Optimiza tu horneado diario con planes de producción generados automáticamente desde predicciones",
"readTime": "10",
"readTime": "16",
"content": {
"intro": "La planificación de producción convierte predicciones de demanda en lotes de horneado concretos, optimizando eficiencia y reduciendo desperdicio.",
"intro": "La planificación de producción convierte predicciones de demanda en lotes de horneado concretos (batches), optimizando eficiencia operativa, reduciendo desperdicio y maximizando utilización de equipos. El sistema integra forecasting, recetas, inventario y capacidad de equipos en un solo flujo automatizado.",
"architecture": {
"title": "Arquitectura del Sistema",
"description": "El sistema usa arquitectura event-driven (orientada a eventos) con coordinación entre microservicios:",
"flow": [
{
"step": "1. Servicio Orquestador (Orchestrator)",
"description": "Coordina todo el flujo. Cada día (o bajo demanda), solicita predicciones al Forecasting Service y dispara generación de planes de producción. Actúa como cerebro central del sistema."
},
{
"step": "2. Servicio de Forecasting",
"description": "Genera predicciones de demanda con Prophet. Devuelve array con: product_id, predicted_demand, confidence_score, historical_average, weather_impact. El Orchestrator pasa estos datos al Production Service."
},
{
"step": "3. Servicio de Producción (Production Service)",
"description": "Recibe forecast → Consulta inventario actual → Calcula production_needed = max(0, predicted_demand - current_stock) → Genera ProductionSchedule + ProductionBatch para cada producto. Endpoint clave: POST /api/v1/tenants/{tenant_id}/production/operations/generate-schedule"
},
{
"step": "4. Integración con Inventario y Recetas",
"description": "Production Service consulta RecipesServiceClient (ingredientes necesarios) e InventoryClient (disponibilidad actual) antes de crear lotes. Valida que hay suficientes ingredientes o emite alertas de stock bajo."
}
]
},
"technicalDetails": {
"title": "Detalles Técnicos de Implementación",
"components": [
{
"component": "ProductionBatch (Lote de Producción)",
"description": "Unidad básica de producción. Cada batch representa una hornada/lote concreto. Estructura: batch_number (formato BATCH-YYYYMMDD-NNN, ej: BATCH-20260113-001), product_id, recipe_id, planned_quantity (unidades a producir), planned_start_time / planned_end_time, planned_duration_minutes (calculado automáticamente), priority (LOW, MEDIUM, HIGH, URGENT), current_process_stage (MIXING, PROOFING, SHAPING, BAKING, COOLING, PACKAGING, FINISHING), status (PENDING, IN_PROGRESS, COMPLETED, ON_HOLD, QUALITY_CHECK, FAILED, CANCELLED). Tracking real: actual_start_time, actual_end_time, actual_quantity, actual_duration_minutes, actual_cost. Métricas de calidad: quality_score (0-100), yield_percentage (actual/planned × 100), waste_quantity, defect_quantity, waste_defect_type (burnt, misshapen, underproofed, temperature_issues)."
},
{
"component": "ProductionSchedule (Plan Diario)",
"description": "Contenedor de todos los batches del día. Estructura: schedule_date (fecha objetivo), shift_start / shift_end (horario laboral), capacity_utilization (% de equipos ocupados), batches_planned (cantidad de lotes), status (DRAFT, FINALIZED, IN_PROGRESS, COMPLETED). Puedes tener múltiples schedules (turnos mañana/tarde/noche). Endpoint para crear: POST /api/v1/tenants/{tenant_id}/production/schedules"
},
{
"component": "ProductionCapacity (Capacidad de Recursos)",
"description": "Tracking de disponibilidad de equipos y personal por día. Campos: resource_type ('equipment' o 'staff'), resource_id (UUID del horno, amasadora, equipo), capacity_date, total_capacity_units (capacidad máxima en horas), reserved_capacity_units (horas ya asignadas a batches), remaining_capacity_units (total - reserved), utilization_percentage ((reserved/total) × 100). Ejemplo: Horno Principal → total: 14 horas (06:00-20:00), reserved: 10.5 horas (3 batches), remaining: 3.5 horas, utilization: 75%."
},
{
"component": "ProcessStage (Etapas de Proceso)",
"description": "Cada batch progresa por etapas secuenciales: MIXING (amasado), PROOFING (fermentación), SHAPING (formado), BAKING (horneado), COOLING (enfriado), PACKAGING (empaquetado), FINISHING (acabado final). Cada etapa puede tener quality checks heredados de la receta. Transición a siguiente etapa requiere completar checks obligatorios. Historial guardado en process_stage_history (JSON) con timestamps."
}
]
},
"features": [
{
"name": "Generación Automática",
"description": "Cada noche a las 5:30 AM, el sistema genera el plan de producción del día basándose en la predicción de demanda"
"name": "Generación Automática desde Forecast",
"description": "El Orchestrator Service dispara generación automática (configuración típica: diaria a las 5:30 AM, sincronizado con forecast). Flujo completo: 1) Orchestrator solicita forecast para próximos 7 días, 2) Production Service recibe array de predicciones, 3) Para cada producto: calcula production_needed = predicted_demand - current_stock, 4) Si production_needed > 0, crea ProductionBatch con: planned_quantity = production_needed, planned_start_time = basado en horario operativo (ej: 06:00 AM), planned_end_time = start + duración calculada de receta, priority = basado en urgencia de forecast (HIGH si demand > stock×2, MEDIUM normal), recipe_id = receta asociada al producto, 5) Valida disponibilidad de ingredientes via InventoryClient, 6) Crea quality checks heredando configuración de la receta, 7) Devuelve schedule_id, batches_created, warnings (si falta stock). Tiempo de generación: 2-3 minutos para catálogo completo (100+ productos). Output: lista de batches en estado PENDING listos para ejecutar."
},
{
"name": "Optimización por Lotes",
"description": "Calcula el tamaño ideal de lote para cada producto. Si vendes 150 baguettes pero tu bandeja es de 40, sugiere 4 lotes (160 total, 6.6% buffer)"
"name": "Creación Manual de Batches",
"description": "Además de generación automática, puedes crear batches manualmente en UI. Casos de uso: Pedido especial de cliente (boda, evento corporativo), Reposición urgente de producto, Prueba de receta nueva, Producción extra por promoción. Formulario de creación incluye: Selector de producto, Selector de receta (auto-carga ingredientes), Cantidad planificada, Fecha y hora de inicio/fin, Duración estimada (auto-calcula si defines inicio/fin), Prioridad (LOW/MEDIUM/HIGH/URGENT), Flags especiales (is_rush_order, is_special_recipe), Asignación de recursos (equipos, personal, estación), Notas de producción (texto libre). Validaciones: Verifica disponibilidad de ingredientes antes de crear, Alerta si capacidad de equipos excedida en ese horario, Confirma que receta existe y está activa. Endpoint: POST /api/v1/tenants/{tenant_id}/production/batches"
},
{
"name": "Secuenciación",
"description": "Ordena los lotes por prioridad y compatibilidad de horno. Productos con mismo tiempo/temperatura se agrupan para eficiencia"
"name": "Optimización por Capacidad de Equipos",
"description": "El sistema rastrea capacidad de cada equipo (hornos, amasadoras, batidoras) por día. Configuración típica: Horno Principal: capacity = 4 bandejas, disponible 06:00-20:00 (14 horas), Horno Secundario: capacity = 2 bandejas, disponible 08:00-18:00 (10 horas), Amasadora Industrial: capacity = 6 lotes/hora, disponible 05:00-15:00 (10 horas). Cuando asignas batch a equipo: Sistema calcula reserved_capacity_units += planned_duration_minutes/60, Actualiza remaining_capacity_units = total - reserved, Calcula utilization_percentage = (reserved/total) × 100, Si utilization > 90%, emite alerta 'capacity_overload'. Dashboard muestra: Timeline visual con slots de tiempo, Barras de utilización por equipo (verde <70%, amarillo 70-90%, rojo >90%), Conflictos de horario (2 batches usando mismo equipo simultáneamente), Sugerencias de optimización (mover batch a otro horario/equipo). Endpoint clave: GET /api/v1/tenants/{tenant_id}/production/schedules?date=YYYY-MM-DD → incluye capacity_utilization por recurso."
},
{
"name": "Integración con Recetas",
"description": "Calcula automáticamente cuánta harina, mantequilla, etc. necesitas según los lotes planificados"
"name": "Secuenciación y Priorización Inteligente",
"description": "El sistema ordena batches por múltiples factores: 1) Priority explícito (URGENT > HIGH > MEDIUM > LOW), 2) Rush order flag (is_rush_order=true sube a URGENT automáticamente), 3) Forecast urgency (si predicted_demand > current_stock × 2 → urgente), 4) Order deadline (si linked a customer order con fecha entrega cercana), 5) Equipment availability (agrupa batches compatibles con mismo equipo). Lógica de agrupación: Productos con misma temperatura/tiempo de horneado se agrupan para minimizar cambios de configuración de horno. Ejemplo: Baguettes (230°C, 25 min) + Pan Rústico (230°C, 30 min) se hornean consecutivamente. Baguettes → Croissants (180°C, 18 min) requiere cambio de temperatura → menos eficiente. Dashboard muestra: Lista ordenada de batches con color-coded priority, Sugerencias de reordenamiento para optimizar equipos, Tiempo total estimado de producción (suma de duraciones), Critical path: secuencia mínima para cumplir todos los deadlines."
},
{
"name": "Integración Profunda con Recetas",
"description": "Cada batch está vinculado a una receta que define: Ingredientes y cantidades (para 1 unidad o 1 lote base), Tiempo de preparación por etapa (mixing: 15 min, proofing: 60 min, baking: 25 min, etc.), Temperatura y equipo requerido (Horno a 230°C, Amasadora espiral), Quality checks por etapa (pesar masa post-mixing, temperatura post-baking), Rendimiento esperado (yield: 95% typical, 5% waste normal). Al crear batch: Sistema llama RecipesServiceClient.calculate_ingredients_for_quantity(recipe_id, planned_quantity) → devuelve ingredient_requirements array, Ejemplo: Batch de 200 baguettes → Harina: 100 kg, Agua: 65 L, Sal: 2 kg, Levadura: 0.8 kg. Sistema valida disponibilidad: InventoryClient.check_availability(ingredient_requirements) → devuelve is_available, missing_items. Si hay ingredientes insuficientes: Crea alerta de stock bajo, Sugiere ajustar planned_quantity a lo disponible, Bloquea batch (status = ON_HOLD) hasta reposición. Cálculo de coste: actual_cost = Σ(ingredient_quantity × ingredient_unit_cost) + labor_cost + energy_cost. Durante producción: Cuando batch = COMPLETED, sistema auto-consume ingredientes del inventario (FIFO), actualiza stock de producto final (+actual_quantity)."
},
{
"name": "Tracking en Tiempo Real y Alertas",
"description": "El Production Scheduler ejecuta cada 5 minutos (APScheduler con leader election para deploys distribuidos). Checks automáticos: 1) Production Delays: Identifica batches donde actual_end_time > planned_end_time. Calcula delay_minutes. Emite alerta si delay > 15 minutos. Muestra batches afectados downstream. 2) Equipment Maintenance Due: Rastrea uso acumulado de equipos (horas de operación). Alerta cuando equipment_maintenance_due_date < today. Muestra days_overdue. 3) Batch Start Delays: Detecta batches en PENDING donde current_time > planned_start_time + 15 min. Previene efecto dominó de retrasos. 4) Quality Check Pending: Batches en QUALITY_CHECK > 30 minutos emiten alerta para manager. Deduplicación: Cache en memoria con TTL 1 hora para evitar spam de alertas. Endpoint alertas: GET /api/v1/tenants/{tenant_id}/production/alerts?active=true. Dashboard Live: Actualización cada 30s (polling), Muestra batches IN_PROGRESS con progreso real-time, Color-coded status (verde on-time, amarillo delayed <30min, rojo delayed >30min), Badges para rush orders y quality checks pendientes."
},
{
"name": "Control de Calidad Stage-Gated",
"description": "Sistema de calidad multi-etapa heredado de recetas. Estructura: QualityTemplate (definido en receta): Especifica process_stage donde aplica check (MIXING, BAKING, COOLING, PACKAGING), check_type (weight, temperature, visual, texture, color, moisture, dimension), target_value y tolerance_percentage (ej: peso target 250g ±5%), required (obligatorio) vs optional, blocking_on_failure (bloquea progreso si falla). Al crear batch: Sistema copia quality templates de la receta a pending_quality_checks JSON del batch. Durante producción: Cuando batch entra en etapa con checks pendientes, UI muestra QualityCheckModal, Operador ingresa measured_value (ej: peso real 248g), Sistema calcula: deviation = |measured - target| / target × 100, pass_fail = deviation <= tolerance_percentage, quality_score = 100 - deviation (max 100), Si pass_fail = false y blocking_on_failure = true: Batch status = QUALITY_CHECK (bloqueado), Manager notificado para review, Puede aprobar excepción o rechazar batch (status = FAILED), Si todos los checks pasan: Batch progresa a siguiente etapa automáticamente, Check movido de pending a completed_quality_checks JSON. Trazabilidad: Cada check registra: operator_name, timestamp, measured_value, pass_fail, notes. Reportes históricos en Dashboard → Quality → Trends: Quality score promedio por producto (últimos 30 días), Defect rate (% batches con checks fallidos), Pass rate por tipo de check. Endpoint: POST /api/v1/tenants/{tenant_id}/production/batches/{batch_id}/quality-checks"
}
],
"iotIntegration": {
"title": "Integración IoT con Equipos Inteligentes",
"description": "BakeWise soporta conexión directa con hornos industriales modernos para automatización completa. Conectores disponibles:",
"connectors": [
{
"brand": "Rational iCombi",
"description": "Integración con plataforma ConnectedCooking. Datos en tiempo real: Temperatura actual del horno (°C), Estado operativo (heating, cooking, cooling, idle), Ciclo de cocción activo (número de ciclo, tiempo restante), Consumo energético (kWh). Automatización: Sistema inicia ciclo de horneado automáticamente cuando batch pasa a BAKING stage, Horno reporta completion → batch auto-update a COOLING stage, Alertas de temperatura fuera de rango (target 230°C, actual 215°C → alerta)."
},
{
"brand": "Wachtel Ovens",
"description": "Integración con sistema REMOTE monitoring. Funcionalidades: Monitoreo de múltiples cámaras independientes, Control de vapor y ventilación, Programas de horneado pre-configurados (baguette, croissant, rústico), Logs de operación detallados para auditoría. BakeWise sincroniza programas de horneado con recetas, auto-selecciona programa correcto por producto."
},
{
"brand": "Generic REST API",
"description": "Conector genérico configurable para cualquier equipo con API REST. Configuración: Base URL del equipo, Authentication (API key, OAuth2, Basic Auth), Endpoints personalizados (start_cycle, get_status, get_temperature), Mapping de campos (tu campo 'temp' → campo API 'current_temperature'). Permite integrar equipos legacy o marcas no soportadas nativamente. Polling interval: 30 segundos (configurable)."
}
],
"benefits": [
"Auto-update de batch status sin intervención manual (actual_start_time, actual_end_time automáticos)",
"Detección temprana de problemas (temperatura baja, fallo de equipo) antes de arruinar lote completo",
"Trazabilidad completa: qué horno, a qué temperatura, cuánto tiempo exactamente para cada batch",
"Optimización energética: reportes de consumo kWh por producto, identifica hornos menos eficientes",
"Mantenimiento predictivo: detecta degradación de performance de equipos antes de fallo total"
]
},
"workflow": [
{
"step": "1. Revisión Matinal",
"description": "Cada mañana, revisa el plan de producción sugerido en el dashboard. Ve todos los lotes del día con horarios sugeridos"
"step": "1. Generación del Plan (Automática o Manual)",
"description": "AUTOMÁTICA: Orchestrator dispara generate-schedule → Production Service crea batches desde forecast. Revisa en Dashboard → Production → Daily Schedule. Ve lista de batches planificados con horarios, cantidades, equipos asignados. MANUAL: Click '+ Create Batch' → Selecciona producto, receta, cantidad → Asigna horario y equipos → Valida ingredientes → Confirma. Batch aparece en schedule con status PENDING."
},
{
"step": "2. Ajustes (Opcional)",
"description": "Si ves que hace buen tiempo o tienes info extra, ajusta cantidades. Los cambios se reflejan automáticamente en ingredientes necesarios"
"step": "2. Revisión y Ajustes Matinales",
"description": "Cada mañana antes de iniciar producción (recomendado 30 min antes de shift): Revisa plan en ProductionSchedule (vista Timeline o Calendar), Verifica capacity utilization (barra verde = OK, amarilla/roja = sobrecargado), Ajusta cantidades si tienes info extra (clima, evento local, pedido urgente): Click en batch → 'Edit' → Modifica planned_quantity, Sistema recalcula ingredient_requirements automáticamente, Valida disponibilidad de ingredientes actualizada, Reordena batches (drag-and-drop) si necesario para optimizar secuencia, Finaliza schedule: Click 'Finalize' → status cambia de DRAFT a FINALIZED (ya no editable sin permisos admin). Tiempo típico: 5-10 minutos de revisión."
},
{
"step": "3. Ejecución",
"description": "Marca lotes como 'En Progreso' cuando empiezas, 'Completado' cuando terminas. Registra cantidad real producida"
"step": "3. Ejecución de Producción (Tracking por Etapas)",
"description": "Operador selecciona primer batch del día, Click 'Start Batch' → status cambia a IN_PROGRESS, Sistema registra actual_start_time automáticamente, Si IoT conectado: horno arranca ciclo automáticamente. Progresión por etapas: MIXING stage: Operador amasa ingredientes, Si hay quality check: QualityCheckModal aparece → pesar masa, ingresar peso real, Confirmar → avanza a PROOFING. PROOFING stage: Masa reposa (timer en UI), Auto-avanza a SHAPING tras tiempo configurado en receta. SHAPING stage: Operador forma piezas, Marca cantidad de piezas shaped (puede ser < planned si masa no rindió). BAKING stage: Batch asignado a horno, Si IoT: auto-start, Si manual: operador marca inicio, Horno reporta temperatura, tiempo restante en live view, Al completar: auto-avanza a COOLING. COOLING stage: Timer de enfriado (configurable por producto), Quality check: medir temperatura interna, Check visual. PACKAGING stage: Empaquetar productos finales, Registrar actual_quantity (cantidad final lista para venta), Puede ser < planned_quantity si hubo defectos/waste. FINISHING stage: Últimos detalles (etiquetado, almacenamiento), Click 'Complete Batch' → status = COMPLETED, Sistema registra actual_end_time, actual_quantity, yield_percentage. Automáticamente: Inventory actualizado (+actual_quantity producto final, -ingredientes consumidos FIFO), Batch agregado a historial de producción."
},
{
"step": "4. Control de Calidad",
"description": "Opcional: registra checks de calidad (peso, textura, color) para seguimiento histórico"
"step": "4. Control de Calidad y Resolución de Issues",
"description": "Durante producción, si quality check falla: Batch bloqueado en status QUALITY_CHECK, Alerta enviada a manager (email + dashboard notification), Manager revisa: Ve measured_value vs target_value, Lee operator notes, Inspecciona batch físicamente si necesario. Decisión: APROBAR: Click 'Approve Exception' → Batch continúa con flag 'quality_exception', RECHAZAR: Click 'Reject Batch' → status = FAILED, Se registra defect_quantity y waste_defect_type, Batch eliminado del schedule activo, Ingredientes no se consumen de inventario (ya que no produjo output válido). Si hay retrasos (batch delayed): Sistema emite alert production_delay_detected, Manager puede: Reasignar recursos (equipo/personal adicional), Extender shift_end_time, Posponer batches no-urgentes, Alertar a ventas si habrá roturas de stock. Troubleshooting equipment: Si horno falla: IoT detecta error_code, Sistema marca batches afectados como ON_HOLD, Maintenance alert creada con days_overdue, Manager reasigna batches a horno alternativo."
},
{
"step": "5. Análisis Post-Producción",
"description": "Al final del día, revisa métricas en Dashboard → Production → Analytics: On-Time Completion Rate: % batches completados dentro de planned_end_time (objetivo >90%), Yield Performance: Promedio yield_percentage (actual/planned), objetivo 95%+, Quality Score Trends: Promedio quality_score por producto, identifica productos problemáticos, Waste & Defect Tracker: Cantidad y tipo de defectos (burnt 10%, underproofed 5%, misshapen 3%), Capacity Utilization: % equipos utilizados, identifica sub-utilización o cuellos de botella, Cost Analysis: actual_cost por batch, compara con coste esperado, identifica desviaciones. Exportable a Excel/PDF para reportes gerenciales. Insights automáticos (AI-powered): 'Producto X tiene yield 10% menor que promedio → revisar receta o capacitación', 'Horno 2 tiene 15% más defectos burnt → calibrar temperatura', 'Batches de tarde tienen 20% más delays → considerar ajustar shift_start_time'."
}
],
"uiComponents": {
"title": "Componentes de Interfaz (Frontend)",
"components": [
{
"component": "ProductionSchedule.tsx",
"path": "/dashboard/production/schedule",
"description": "Vista principal de planificación. Modos: Timeline (horizontal time-based), Calendar (día por día), Capacity (utilización de equipos). Features: Drag-and-drop para reordenar batches, Color-coded por status (PENDING=gris, IN_PROGRESS=azul, COMPLETED=verde, ON_HOLD=naranja, FAILED=rojo), Filtros por status, producto, categoría, prioridad, Equipment capacity bars (verde/amarillo/rojo según utilización), Click en batch abre detalle modal con full info + edit."
},
{
"component": "CreateProductionBatchModal.tsx",
"description": "Modal para crear batch manualmente. Secciones: Product Information (producto, receta con auto-load de detalles), Production Schedule (start/end time, duration auto-calc, quantity), Resource Allocation (equipment multi-select, staff IDs, station), Order Context (order_id si es para pedido, forecast_id si auto-generado, flags: rush_order, special_recipe), Production Notes (texto libre para instrucciones especiales). Validations: End > Start time, Quantity > 0, Duration > 0, Ingredient availability check pre-save. API: POST /api/v1/tenants/{tenant_id}/production/batches"
},
{
"component": "ProcessStageTracker.tsx",
"description": "Visual tracker de progreso del batch por etapas. Diseño: Stepper horizontal con 7 stages (MIXING → PROOFING → SHAPING → BAKING → COOLING → PACKAGING → FINISHING), Stage actual highlighted en azul, completados en verde, pendientes en gris, Si hay quality check pendiente en stage: ícono badge rojo con número de checks. Click en stage: Muestra detalles (start_time, duration, operator, quality_score si aplicable), Si stage actual: botones 'Complete Stage' o 'Quality Check'."
},
{
"component": "QualityCheckModal.tsx",
"description": "Modal para ingresar resultados de quality checks. Campos dinámicos según check_type: WEIGHT: Input para peso medido (g/kg), target weight visible, tolerance %, TEMPERATURE: Input para temperatura (°C), target temp, tolerance, VISUAL: Radio buttons (Pass/Fail), text area para notas, TEXTURE: Scale 1-5, text area descripción, COLOR: Color picker + reference image. Auto-calcula: deviation %, quality_score, pass_fail boolean. Si fail + blocking: Alerta 'This check is blocking, batch will be put ON HOLD pending manager review'. Submit → API: POST /batches/{batch_id}/quality-checks → actualiza pending_quality_checks."
},
{
"component": "LiveBatchTrackerWidget.tsx",
"path": "/dashboard (widget)",
"description": "Widget en dashboard mostrando batches activos en tiempo real. Lista compacta: Product name, current_process_stage, time_remaining (ETA to completion), progress bar visual (% stages completados), Status badge (IN_PROGRESS verde, QUALITY_CHECK amarillo, delayed rojo). Actualización: Polling cada 30s. Click en batch: Navega a batch detail page. Muestra max 5 batches, link 'View All' para página completa."
}
]
},
"optimizationTips": [
{
"tip": "Batch Sizing Strategy",
"description": "Tamaño de lote óptimo depende de: Equipment capacity (no exceder capacidad de bandeja/horno), Demand forecast (producir lo necesario +5-10% buffer, no mucho más para evitar waste), Recipe scalability (algunas recetas no escalan linealmente: masa madre funciona mejor en lotes 50-100 kg, no 10 kg ni 500 kg). Recomendación: Si predicted_demand = 150 baguettes y bandeja = 40, opciones: Opción A: 4 lotes de 40 = 160 total (6.6% buffer, OK), Opción B: 3 lotes de 50 = 150 total (0% buffer, RISKY si hay defectos), Opción C: 2 lotes de 80 (si bandeja lo permite) = 160 total (menos cambios de horno, más eficiente). Sistema no optimiza automáticamente (futuro roadmap), tú decides basándote en experiencia."
},
{
"tip": "Equipment Utilization Optimization",
"description": "Objetivo: 70-85% utilization (no 100%, necesitas slack para urgencias). Estrategias: Agrupar productos compatibles (misma temperatura): Baguettes 230°C + Pan Rústico 230°C consecutivos (sin cambio configuración), Evitar alternar caliente-frío-caliente: Pan 230°C → Croissant 180°C → Pan 230°C (desperdicia energía calentando/enfriando), Usar hornos secundarios para productos menores: Horno principal para pan (alto volumen), horno secundario para especiales/pruebas, Mantenimiento preventivo en low-demand days: Si martes históricamente -20% ventas, programa limpieza profunda de equipos ese día."
},
{
"tip": "Buffer Management",
"description": "El sistema NO calcula buffer matemático 5-10% automáticamente (por diseño, te da control). Debes aplicar buffer manualmente: En batch creation, ajusta planned_quantity = predicted_demand × 1.05 (5% buffer) o × 1.10 (10%). Cuándo usar buffer alto (10%): Productos con alta variabilidad de yield (pastelería delicada), Días de alta incertidumbre (festivos, clima extremo), Productos con largo lead time de reposición (si se rompe stock, no hay tiempo de hacer más). Cuándo usar buffer bajo (5% o 0%): Productos muy perecederos (mejor quedarse corto que tirar mucho), Productos con yield muy estable (pan básico, >95% yield), Días con forecast alta confidence (>90%). Tracking: Dashboard → Production → Yield Performance muestra tu yield real promedio. Si sistemáticamente produces 102% (2% más de lo planificado), puedes reducir buffer."
},
{
"tip": "Process Stage Duration Optimization",
"description": "Recetas definen duración por etapa, pero hay optimización posible: PROOFING: Varía con temperatura ambiente. Verano (25°C): -15% tiempo, Invierno (15°C): +20% tiempo. Sistema no ajusta automáticamente, pero puedes: Crear recipe_variants (recipe_summer, recipe_winter), Ajustar planned_duration_minutes manualmente en batch al crearlo. COOLING: Acortar usando racks de enfriamiento forzado, Permite pasar a PACKAGING más rápido, Aumenta throughput. BAKING: No acortar (afecta calidad), pero puedes: Optimizar carga del horno (llenar todas las bandejas disponibles), Usar funciones avanzadas de horno (convección, vapor) para cocción más uniforme y rápida."
}
],
"troubleshooting": [
{
"problem": "Batches sistemáticamente delayed (>30 min retraso)",
"solutions": [
"Revisa planned_duration_minutes en recetas: ¿es realista? Compara con actual_duration_minutes histórico (Dashboard → Production → Batch History)",
"Identifica cuellos de botella: ¿siempre se atrasa en misma etapa? (ej: PROOFING tarda más de lo planificado → ajusta tiempo en receta)",
"Verifica capacity: ¿hay conflictos de equipos? (2 batches usando mismo horno simultáneamente → sistema alertará pero no bloqueará)",
"Considera añadir personal/equipos: Si utilization consistentemente >90%, necesitas más capacidad física",
"Reordena batches: Productos urgentes (rush_order) deben ir primero en schedule"
]
},
{
"problem": "Yield bajo (<90%, mucho waste o defects)",
"solutions": [
"Analiza defect_type en Dashboard → Waste Tracker: Si burnt/overcooked: Calibrar temperatura de horno (puede estar descalibrado +10-15°C), Si underproofed: Aumentar tiempo de PROOFING en receta, verificar temperatura ambiente, Si misshapen: Revisar SHAPING stage, capacitar equipo, mejorar técnica",
"Revisa quality checks históricos: ¿en qué stage fallan más? Identifica etapa problemática",
"Compara yield entre diferentes hornos/equipos: Si Horno 1 yield 95% vs Horno 2 yield 85% → problema de equipo, no de proceso",
"Ingredientes: Verifica calidad de ingredientes (harina vieja, levadura débil → bajo yield)",
"Sobrecarga de operador: ¿personal manejando demasiados batches simultáneos? → Reduce batches concurrentes"
]
},
{
"problem": "Ingredientes insuficientes para producción planificada",
"solutions": [
"Alert ingredient_shortage aparece al generar schedule. Opciones: Ajustar planned_quantity de batches a lo disponible (sistema sugiere max_producible con stock actual), Postponer batches no-urgentes (LOW priority) para mañana, Crear orden de compra urgente (Dashboard → Procurement → Create Order) y poner batches ON_HOLD hasta recibir ingredientes",
"Prevención: Configura reorder_point en inventario para cada ingrediente crítico. Fórmula: reorder_point = (consumo_diario_promedio × supplier_lead_time_days) × 1.2 (20% margen). Ejemplo: Harina consume 50 kg/día, proveedor entrega en 2 días → reorder_point = 50×2×1.2 = 120 kg. Alert cuando stock < 120 kg",
"Usa Production → Ingredient Requirements report: Proyección de consumo próximos 7 días basada en batches planificados. Compara con inventory actual → identifica faltantes antes de que ocurran"
]
}
],
"advancedFeatures": [
{
"feature": "Multi-Shift Planning",
"description": "Si operas múltiples turnos (mañana/tarde/noche), crea ProductionSchedule separado por shift: Shift Mañana: 06:00-14:00 (pan fresco para desayuno/almuerzo), Shift Tarde: 14:00-22:00 (reposición + bollería para día siguiente), Shift Noche: 22:00-06:00 (pre-producción, fermentaciones largas). Cada schedule tiene su capacity_utilization y staff_assigned independiente. Beneficios: Claridad de qué equipo hace qué, Optimización de personal (chef experto en turno crítico), Planificación de mantenimiento (limpiar equipos entre shifts)."
},
{
"feature": "Batch Templates (Próximamente)",
"description": "Roadmap: Crear templates de batches recurrentes. Ejemplo: Template 'Lunes Estándar' con 10 batches predefinidos (baguettes ×200, croissants ×80, etc.). Un click → crea todos los batches del template. Ahorra tiempo de configuración semanal."
},
{
"feature": "Predictive Maintenance (Roadmap ML)",
"description": "Futuro: ML analiza historical equipment performance. Predice: 'Horno 1 tiene 85% probabilidad de fallar en próximos 7 días basado en degradación de performance'. Alerta proactiva antes de fallo → programa mantenimiento preventivo → evita downtime en medio de producción."
}
],
"tips": [
"El buffer automático es 5-10% extra para absorber variabilidad. Ajustable en Configuración",
"Si produces de más sistemáticamente, el sistema lo detecta y ajusta las recomendaciones",
"Puedes bloquear horarios de horno para mantenimiento o productos especiales"
]
"CLAVE: Revisa el plan 30 min antes de iniciar producción cada día. Ajustes de último minuto son normales (clima, pedidos urgentes, staff ausente).",
"Prioriza finalizar batches IN_PROGRESS antes de iniciar nuevos. Tener muchos batches parcialmente completados reduce eficiencia.",
"Usa priority flags consistentemente: URGENT solo para verdaderas urgencias (rotura de stock inminente, pedido cliente con deadline hoy). Abusar de URGENT diluye su efecto.",
"Quality checks son inversión, no overhead. Catch defectos en MIXING stage (coste: 5 min + ingredientes) vs descubrir en PACKAGING (coste: 2 horas + todos los ingredientes + energía de horneado).",
"IoT integration paga su ROI en 6-12 meses típicamente: Ahorro de labor (no registrar manualmente), reducción de defectos (alertas tempranas), optimización energética (reportes consumo).",
"Si produces <50 batches/semana: planificación manual es suficiente. Si produces >200 batches/semana: automatización es esencial para no perder tiempo en logística.",
"El sistema aprende de tus ajustes: Si consistentemente editas planned_quantity al alza +10%, futuras generaciones automáticas aplicarán ese patrón."
],
"conclusion": "La planificación de producción automatizada es el puente entre predicciones (qué vender) y realidad operativa (qué hornear, cuándo, cómo). Invierte en: 1) Recetas precisas (tiempos, ingredientes, quality checks bien definidos), 2) Capacidad de equipos actualizada (actualiza si compras horno nuevo, aumentas turnos), 3) Tracking disciplinado (marcar estados de batches consistentemente, registrar quality checks sin fallar), 4) Análisis semanal de métricas (yield, on-time completion, defects) para mejora continua. Con estos 4 pilares, reducirás waste 30-50%, aumentarás throughput 20-35% (mismo personal/equipos producen más), mejorarás calidad consistente (less variability = happier customers)."
}
},
"inventoryManagement": {

View File

@@ -36,7 +36,8 @@ export interface AuthState {
}> | null;
primaryTenantId: string | null;
subscription_from_jwt?: boolean;
pendingSubscriptionId?: string | null; // Subscription ID from registration (before tenant creation)
// Actions
login: (email: string, password: string) => Promise<void>;
register: (userData: {
@@ -45,7 +46,6 @@ export interface AuthState {
full_name: string;
tenant_name?: string;
subscription_plan?: string;
use_trial?: boolean;
payment_method_id?: string;
}) => Promise<void>;
logout: () => void;
@@ -54,6 +54,7 @@ export interface AuthState {
clearError: () => void;
setLoading: (loading: boolean) => void;
setDemoAuth: (token: string, demoUser: Partial<User>, subscriptionTier?: string) => void;
setPendingSubscriptionId: (subscriptionId: string | null) => void; // Store subscription ID from registration
// Permission helpers
hasPermission: (permission: string) => boolean;
@@ -73,6 +74,10 @@ export const useAuthStore = create<AuthState>()(
isAuthenticated: false,
isLoading: false,
error: null,
jwtSubscription: null,
jwtTenantAccess: null,
primaryTenantId: null,
pendingSubscriptionId: null,
// Actions
login: async (email: string, password: string) => {
@@ -126,7 +131,6 @@ export const useAuthStore = create<AuthState>()(
full_name: string;
tenant_name?: string;
subscription_plan?: string;
use_trial?: boolean;
payment_method_id?: string;
}) => {
try {
@@ -165,6 +169,60 @@ export const useAuthStore = create<AuthState>()(
}
},
registerWithSubscription: async (userData: {
email: string;
password: string;
full_name: string;
tenant_name?: string;
subscription_plan?: string;
payment_method_id?: string;
billing_cycle?: 'monthly' | 'yearly';
coupon_code?: string;
address?: string;
postal_code?: string;
city?: string;
country?: string;
}) => {
try {
set({ isLoading: true, error: null });
const response = await authService.registerWithSubscription(userData);
if (response && response.access_token) {
// Set the auth tokens on the API client immediately
apiClient.setAuthToken(response.access_token);
if (response.refresh_token) {
apiClient.setRefreshToken(response.refresh_token);
}
// Store subscription ID in state for onboarding flow (instead of localStorage for security)
const pendingSubscriptionId = response.subscription_id || null;
set({
user: response.user || null,
token: response.access_token,
refreshToken: response.refresh_token || null,
isAuthenticated: true,
isLoading: false,
error: null,
pendingSubscriptionId,
});
} else {
throw new Error('Registration with subscription failed');
}
} catch (error) {
set({
user: null,
token: null,
refreshToken: null,
isAuthenticated: false,
isLoading: false,
error: error instanceof Error ? error.message : 'Error de registro con suscripción',
});
throw error;
}
},
logout: () => {
// Clear the auth tokens from API client
apiClient.setAuthToken(null);
@@ -189,6 +247,10 @@ export const useAuthStore = create<AuthState>()(
isAuthenticated: false,
isLoading: false,
error: null,
jwtSubscription: null,
jwtTenantAccess: null,
primaryTenantId: null,
pendingSubscriptionId: null,
});
},
@@ -261,6 +323,10 @@ export const useAuthStore = create<AuthState>()(
set({ isLoading: loading });
},
setPendingSubscriptionId: (subscriptionId: string | null) => {
set({ pendingSubscriptionId: subscriptionId });
},
setDemoAuth: (token: string, demoUser: Partial<User>, subscriptionTier?: string) => {
console.log('🔧 [Auth Store] setDemoAuth called - demo sessions use X-Demo-Session-Id header, not JWT');
// DO NOT set API client token for demo sessions!
@@ -379,6 +445,7 @@ export const usePermissions = () => useAuthStore((state) => ({
export const useAuthActions = () => useAuthStore((state) => ({
login: state.login,
register: state.register,
registerWithSubscription: state.registerWithSubscription,
logout: state.logout,
refreshAuth: state.refreshAuth,
updateUser: state.updateUser,

View File

@@ -11,9 +11,13 @@ import type { SubscriptionTier } from '../api';
* Generate register URL with proper query parameters
*
* @param planTier - Optional subscription plan tier (starter, professional, enterprise)
* @param billingCycle - Optional billing cycle ('monthly' or 'yearly')
* @returns Register URL with appropriate query parameters
*
* @example
* // In pilot mode with plan and billing cycle selected
* getRegisterUrl('starter', 'yearly') // => '/register?pilot=true&plan=starter&billing_cycle=yearly'
*
* // In pilot mode with plan selected
* getRegisterUrl('starter') // => '/register?pilot=true&plan=starter'
*
@@ -23,7 +27,7 @@ import type { SubscriptionTier } from '../api';
* // Not in pilot mode with plan
* getRegisterUrl('professional') // => '/register?plan=professional'
*/
export const getRegisterUrl = (planTier?: SubscriptionTier | string): string => {
export const getRegisterUrl = (planTier?: SubscriptionTier | string, billingCycle?: 'monthly' | 'yearly'): string => {
const params = new URLSearchParams();
// Add pilot parameter if pilot mode is enabled globally
@@ -36,6 +40,11 @@ export const getRegisterUrl = (planTier?: SubscriptionTier | string): string =>
params.set('plan', planTier);
}
// Add billing cycle parameter if specified
if (billingCycle) {
params.set('billing_cycle', billingCycle);
}
const queryString = params.toString();
return `/register${queryString ? '?' + queryString : ''}`;
};

View File

@@ -25,7 +25,7 @@ from app.middleware.rate_limiting import APIRateLimitMiddleware
from app.middleware.subscription import SubscriptionMiddleware
from app.middleware.demo_middleware import DemoMiddleware
from app.middleware.read_only_mode import ReadOnlyModeMiddleware
from app.routes import auth, tenant, nominatim, subscription, demo, pos, geocoding, poi_context
from app.routes import auth, tenant, nominatim, subscription, demo, pos, geocoding, poi_context, webhooks
# Initialize logger
logger = structlog.get_logger()
@@ -122,6 +122,9 @@ app.include_router(nominatim.router, prefix="/api/v1/nominatim", tags=["location
app.include_router(geocoding.router, prefix="/api/v1/geocoding", tags=["geocoding"])
app.include_router(pos.router, prefix="/api/v1/pos", tags=["pos"])
app.include_router(demo.router, prefix="/api/v1", tags=["demo"])
app.include_router(webhooks.router, prefix="/api/v1", tags=["webhooks"])
# Also include webhooks at /webhooks prefix to support direct webhook URLs like /webhooks/stripe
app.include_router(webhooks.router, prefix="/webhooks", tags=["webhooks-external"])
# ================================================================

View File

@@ -24,6 +24,11 @@ router = APIRouter()
service_discovery = ServiceDiscovery()
metrics = MetricsCollector("gateway")
# Register custom metrics for auth routes
metrics.register_counter("gateway_auth_requests_total", "Total authentication requests through gateway")
metrics.register_counter("gateway_auth_responses_total", "Total authentication responses from gateway")
metrics.register_counter("gateway_auth_errors_total", "Total authentication errors in gateway")
# Auth service configuration
AUTH_SERVICE_URL = settings.AUTH_SERVICE_URL or "http://auth-service:8000"

View File

@@ -54,6 +54,18 @@ async def proxy_subscription_cancel(request: Request):
target_path = "/api/v1/subscriptions/cancel"
return await _proxy_to_tenant_service(request, target_path)
@router.api_route("/subscriptions/create-for-registration", methods=["POST", "OPTIONS"])
async def proxy_create_for_registration(request: Request):
"""Proxy create-for-registration request to tenant service"""
target_path = "/api/v1/subscriptions/create-for-registration"
return await _proxy_to_tenant_service(request, target_path)
@router.api_route("/payment-customers/create", methods=["POST", "OPTIONS"])
async def proxy_payment_customer_create(request: Request):
"""Proxy payment customer creation request to tenant service"""
target_path = "/api/v1/payment-customers/create"
return await _proxy_to_tenant_service(request, target_path)
@router.api_route("/subscriptions/reactivate", methods=["POST", "OPTIONS"])
async def proxy_subscription_reactivate(request: Request):
"""Proxy subscription reactivation request to tenant service"""

View File

@@ -0,0 +1,107 @@
"""
Webhook routes for API Gateway - Handles webhook endpoints
"""
from fastapi import APIRouter, Request, Response
from fastapi.responses import JSONResponse
import httpx
import logging
from typing import Optional
from app.core.config import settings
from app.core.header_manager import header_manager
logger = logging.getLogger(__name__)
router = APIRouter()
# ================================================================
# WEBHOOK ENDPOINTS - Direct routing to tenant service
# ================================================================
@router.post("/stripe")
async def proxy_stripe_webhook(request: Request):
"""Proxy Stripe webhook requests to tenant service"""
return await _proxy_to_tenant_service(request, "/webhooks/stripe")
@router.post("/generic")
async def proxy_generic_webhook(request: Request):
"""Proxy generic webhook requests to tenant service"""
return await _proxy_to_tenant_service(request, "/webhooks/generic")
# ================================================================
# PROXY HELPER FUNCTIONS
# ================================================================
async def _proxy_to_tenant_service(request: Request, target_path: str):
"""Proxy request to tenant service"""
return await _proxy_request(request, target_path, settings.TENANT_SERVICE_URL)
async def _proxy_request(request: Request, target_path: str, service_url: str):
"""Generic proxy function with enhanced error handling"""
# Handle OPTIONS requests directly for CORS
if request.method == "OPTIONS":
return Response(
status_code=200,
headers={
"Access-Control-Allow-Origin": settings.CORS_ORIGINS_LIST,
"Access-Control-Allow-Methods": "GET, POST, PUT, DELETE, OPTIONS",
"Access-Control-Allow-Headers": "Content-Type, Authorization, X-Tenant-ID, Stripe-Signature",
"Access-Control-Allow-Credentials": "true",
"Access-Control-Max-Age": "86400"
}
)
try:
url = f"{service_url}{target_path}"
# Use unified HeaderManager for consistent header forwarding
headers = header_manager.get_all_headers_for_proxy(request)
# Debug logging
logger.info(f"Forwarding webhook request to {url}")
# Get request body if present
body = None
if request.method in ["POST", "PUT", "PATCH"]:
body = await request.body()
# Add query parameters
params = dict(request.query_params)
timeout_config = httpx.Timeout(
connect=30.0,
read=60.0,
write=30.0,
pool=30.0
)
async with httpx.AsyncClient(timeout=timeout_config) as client:
response = await client.request(
method=request.method,
url=url,
headers=headers,
content=body,
params=params
)
# Handle different response types
if response.headers.get("content-type", "").startswith("application/json"):
try:
content = response.json()
except:
content = {"message": "Invalid JSON response from service"}
else:
content = response.text
return JSONResponse(
status_code=response.status_code,
content=content
)
except Exception as e:
logger.error(f"Unexpected error proxying webhook request to {service_url}{target_path}: {e}")
raise HTTPException(
status_code=500,
detail="Internal gateway error"
)

View File

@@ -375,7 +375,7 @@ data:
VITE_PILOT_MODE_ENABLED: "true"
VITE_PILOT_COUPON_CODE: "PILOT2025"
VITE_PILOT_TRIAL_MONTHS: "3"
VITE_STRIPE_PUBLISHABLE_KEY: "pk_test_your_stripe_publishable_key_here"
VITE_STRIPE_PUBLISHABLE_KEY: "pk_test_51QuxKyIzCdnBmAVTGM8fvXYkItrBUILz6lHYwhAva6ZAH1HRi0e8zDRgZ4X3faN0zEABp5RHjCVBmMJL3aKXbaC200fFrSNnPl"
# ================================================================
# LOCATION SETTINGS (Nominatim Geocoding)

View File

@@ -146,8 +146,8 @@ metadata:
app.kubernetes.io/component: payments
type: Opaque
data:
STRIPE_SECRET_KEY: c2tfdGVzdF95b3VyX3N0cmlwZV9zZWNyZXRfa2V5X2hlcmU= # sk_test_your_stripe_secret_key_here
STRIPE_WEBHOOK_SECRET: d2hzZWNfeW91cl9zdHJpcGVfd2ViaG9va19zZWNyZXRfaGVyZQ== # whsec_your_stripe_webhook_secret_here
STRIPE_SECRET_KEY: c2tfdGVzdF81MVF1eEt5SXpDZG5CbUFWVG5QYzhVWThZTW1qdUJjaTk0RzRqc2lzMVQzMFU1anV5ZmxhQkJxYThGb2xEdTBFMlNnOUZFcVNUakFxenUwa0R6eTROUUN3ejAwOGtQUFF6WGM= # sk_test_51QuxKyIzCdnBmAVTnPc8UY8YMmjuBci94G4jsis1T30U5juyflaBBqa8FolDu0E2Sg9FEqSTjAqzu0kDzy4NQCwz008kPPQzXc
STRIPE_WEBHOOK_SECRET: d2hzZWNfOWI1NGM2ZDQ2ZjhlN2E4NWQzZWZmNmI5MWQyMzg3NGQ3N2Q5NjBlZGUyYWQzNTBkOWY3MWY5ZjBmYTlkM2VjNQ== # whsec_9b54c6d46f8e7a85d3eff6b91d23874d77d960ede2ad350d9f71f9f0fa9d3ec5
---
apiVersion: v1

View File

@@ -0,0 +1,129 @@
#!/bin/bash
# Script to generate a comprehensive test report for the subscription creation flow
# This script checks all components and generates a detailed report
echo "📊 Generating Subscription Creation Flow Test Report"
echo "===================================================="
echo "Report generated on: $(date)"
echo ""
# Test 1: Check if database migration was applied
echo "🔍 Test 1: Database Migration Check"
echo "-----------------------------------"
POD_NAME=$(kubectl get pods -n bakery-ia -l app=auth-service -o jsonpath='{.items[0].metadata.name}')
MIGRATION_STATUS=$(kubectl exec -n bakery-ia $POD_NAME -- psql -U auth_user -d auth_db -c "SELECT version_num FROM alembic_version" -t -A)
if [[ "$MIGRATION_STATUS" == "20260113_add_payment_columns" ]]; then
echo "✅ PASS: Database migration '20260113_add_payment_columns' is applied"
else
echo "❌ FAIL: Database migration not found. Current version: $MIGRATION_STATUS"
fi
echo ""
# Test 2: Check if payment columns exist in users table
echo "🔍 Test 2: Payment Columns in Users Table"
echo "------------------------------------------"
COLUMNS=$(kubectl exec -n bakery-ia $POD_NAME -- psql -U auth_user -d auth_db -c "\d users" -t -A | grep -E "payment_customer_id|default_payment_method_id")
if [[ -n "$COLUMNS" ]]; then
echo "✅ PASS: Payment columns found in users table"
echo " Found columns:"
echo " $COLUMNS" | sed 's/^/ /'
else
echo "❌ FAIL: Payment columns not found in users table"
fi
echo ""
# Test 3: Check if gateway route exists
echo "🔍 Test 3: Gateway Route Configuration"
echo "--------------------------------------"
GATEWAY_POD=$(kubectl get pods -n bakery-ia -l app=gateway -o jsonpath='{.items[0].metadata.name}')
ROUTE_CHECK=$(kubectl exec -n bakery-ia $GATEWAY_POD -- grep -c "create-for-registration" /app/app/routes/subscription.py)
if [[ "$ROUTE_CHECK" -gt 0 ]]; then
echo "✅ PASS: Gateway route for 'create-for-registration' is configured"
else
echo "❌ FAIL: Gateway route for 'create-for-registration' not found"
fi
echo ""
# Test 4: Check if tenant service endpoint exists
echo "🔍 Test 4: Tenant Service Endpoint"
echo "-----------------------------------"
TENANT_POD=$(kubectl get pods -n bakery-ia -l app=tenant-service -o jsonpath='{.items[0].metadata.name}')
ENDPOINT_CHECK=$(kubectl exec -n bakery-ia $TENANT_POD -- grep -c "create-for-registration" /app/app/api/subscription.py)
if [[ "$ENDPOINT_CHECK" -gt 0 ]]; then
echo "✅ PASS: Tenant service endpoint 'create-for-registration' is configured"
else
echo "❌ FAIL: Tenant service endpoint 'create-for-registration' not found"
fi
echo ""
# Test 5: Test user registration (create a test user)
echo "🔍 Test 5: User Registration Test"
echo "--------------------------------"
TEST_EMAIL="test_$(date +%Y%m%d%H%M%S)@example.com"
REGISTRATION_RESPONSE=$(curl -X POST "https://bakery-ia.local/api/v1/auth/register-with-subscription" \
-H "Content-Type: application/json" \
-H "Accept: application/json" \
-d "{\"email\":\"$TEST_EMAIL\",\"password\":\"SecurePassword123!\",\"full_name\":\"Test User\",\"subscription_plan\":\"basic\",\"payment_method_id\":\"pm_test123\"}" \
-k -s)
if echo "$REGISTRATION_RESPONSE" | grep -q "access_token"; then
echo "✅ PASS: User registration successful"
USER_ID=$(echo "$REGISTRATION_RESPONSE" | python3 -c "import sys, json; print(json.load(sys.stdin)['user']['id'])")
echo " Created user ID: $USER_ID"
else
echo "❌ FAIL: User registration failed"
echo " Response: $REGISTRATION_RESPONSE"
fi
echo ""
# Test 6: Check if user has payment fields
echo "🔍 Test 6: User Payment Fields"
echo "------------------------------"
if [[ -n "$USER_ID" ]]; then
USER_DATA=$(kubectl exec -n bakery-ia $POD_NAME -- psql -U auth_user -d auth_db -c "SELECT payment_customer_id, default_payment_method_id FROM users WHERE id = '$USER_ID'" -t -A)
if [[ -n "$USER_DATA" ]]; then
echo "✅ PASS: User has payment fields in database"
echo " Payment data: $USER_DATA"
else
echo "❌ FAIL: User payment fields not found"
fi
else
echo "⚠️ SKIP: User ID not available from previous test"
fi
echo ""
# Test 7: Check subscription creation in onboarding progress
echo "🔍 Test 7: Subscription in Onboarding Progress"
echo "---------------------------------------------"
if [[ -n "$USER_ID" ]]; then
# This would require authentication, so we'll skip for now
echo "⚠️ SKIP: Requires authentication (would need to implement token handling)"
else
echo "⚠️ SKIP: User ID not available from previous test"
fi
echo ""
# Summary
echo "📋 Test Summary"
echo "==============="
echo "The subscription creation flow test report has been generated."
echo ""
echo "Components tested:"
echo " 1. Database migration"
echo " 2. Payment columns in users table"
echo " 3. Gateway route configuration"
echo " 4. Tenant service endpoint"
echo " 5. User registration"
echo " 6. User payment fields"
echo " 7. Subscription in onboarding progress"
echo ""
echo "For a complete integration test, run:"
echo " ./scripts/run_subscription_integration_test.sh"
echo ""
echo "🎉 Report generation completed!"

View File

@@ -0,0 +1,145 @@
#!/bin/bash
# Script to run the subscription creation integration test inside Kubernetes
# This script creates a test pod that runs the integration test
set -e
echo "🚀 Starting subscription creation integration test..."
# Check if there's already a test pod running
EXISTING_POD=$(kubectl get pod subscription-integration-test -n bakery-ia 2>/dev/null || echo "")
if [ -n "$EXISTING_POD" ]; then
echo "🧹 Cleaning up existing test pod..."
kubectl delete pod subscription-integration-test -n bakery-ia --wait=true
echo "✅ Existing pod cleaned up"
fi
# Determine the correct image to use by checking the existing tenant service deployment
IMAGE=$(kubectl get deployment tenant-service -n bakery-ia -o jsonpath='{.spec.template.spec.containers[0].image}')
if [ -z "$IMAGE" ]; then
echo "❌ Could not determine tenant service image. Is the tenant service deployed?"
exit 1
fi
echo "📦 Using image: $IMAGE"
# Create a test pod that runs the integration test with a simple command
echo "🔧 Creating test pod..."
kubectl run subscription-integration-test \
--image="$IMAGE" \
--namespace=bakery-ia \
--restart=Never \
--env="GATEWAY_URL=http://gateway-service:8000" \
--env="STRIPE_SECRET_KEY=$(kubectl get secret payment-secrets -n bakery-ia -o jsonpath='{.data.STRIPE_SECRET_KEY}' | base64 -d)" \
--command -- /bin/sh -c "
set -e
echo '🧪 Setting up test environment...' &&
cd /app &&
echo '📋 Installing test dependencies...' &&
pip install pytest pytest-asyncio httpx stripe --quiet &&
echo '✅ Dependencies installed' &&
echo '' &&
echo '🔧 Configuring test to use internal gateway service URL...' &&
# Backup original file before modification
cp tests/integration/test_subscription_creation_flow.py tests/integration/test_subscription_creation_flow.py.bak &&
# Update the test file to use the internal gateway service URL
sed -i 's|self.base_url = \"https://bakery-ia.local\"|self.base_url = \"http://gateway-service:8000\"|g' tests/integration/test_subscription_creation_flow.py &&
echo '✅ Test configured for internal Kubernetes networking' &&
echo '' &&
echo '🧪 Running subscription creation integration test...' &&
echo '━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━' &&
python -m pytest tests/integration/test_subscription_creation_flow.py -v --tb=short -s --color=yes &&
TEST_RESULT=\$? &&
echo '━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━' &&
echo '' &&
echo '📋 Restoring original test file...' &&
mv tests/integration/test_subscription_creation_flow.py.bak tests/integration/test_subscription_creation_flow.py &&
echo '✅ Original test file restored' &&
echo '' &&
if [ \$TEST_RESULT -eq 0 ]; then
echo '🎉 Integration test PASSED!'
else
echo '❌ Integration test FAILED!'
fi &&
exit \$TEST_RESULT
"
# Wait for the test pod to start
echo "⏳ Waiting for test pod to start..."
sleep 5
# Follow the logs in real-time
echo "📋 Following test execution logs..."
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo ""
# Stream logs while the pod is running
kubectl logs -f subscription-integration-test -n bakery-ia 2>/dev/null || true
echo ""
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
# Wait for the pod to complete with a timeout
echo "⏳ Waiting for test pod to complete..."
TIMEOUT=600 # 10 minutes timeout
COUNTER=0
while [ $COUNTER -lt $TIMEOUT ]; do
POD_STATUS=$(kubectl get pod subscription-integration-test -n bakery-ia -o jsonpath='{.status.phase}' 2>/dev/null)
if [ "$POD_STATUS" == "Succeeded" ] || [ "$POD_STATUS" == "Failed" ]; then
break
fi
sleep 2
COUNTER=$((COUNTER + 2))
done
if [ $COUNTER -ge $TIMEOUT ]; then
echo "⏰ Timeout waiting for test to complete after $TIMEOUT seconds"
echo "📋 Fetching final logs before cleanup..."
kubectl logs subscription-integration-test -n bakery-ia --tail=100
echo "🧹 Cleaning up test pod due to timeout..."
kubectl delete pod subscription-integration-test -n bakery-ia --wait=false
exit 1
fi
# Get the final status
POD_STATUS=$(kubectl get pod subscription-integration-test -n bakery-ia -o jsonpath='{.status.phase}')
CONTAINER_EXIT_CODE=$(kubectl get pod subscription-integration-test -n bakery-ia -o jsonpath='{.status.containerStatuses[0].state.terminated.exitCode}' 2>/dev/null || echo "unknown")
echo ""
echo "📊 Test Results:"
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo "Pod Status: $POD_STATUS"
echo "Exit Code: $CONTAINER_EXIT_CODE"
# Determine if the test passed
if [ "$POD_STATUS" == "Succeeded" ] && [ "$CONTAINER_EXIT_CODE" == "0" ]; then
echo ""
echo "✅ Integration test PASSED!"
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
RESULT=0
else
echo ""
echo "❌ Integration test FAILED!"
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
# Show additional logs if failed
if [ "$POD_STATUS" == "Failed" ]; then
echo ""
echo "📋 Last 50 lines of logs:"
kubectl logs subscription-integration-test -n bakery-ia --tail=50
fi
RESULT=1
fi
# Clean up the test pod
echo ""
echo "🧹 Cleaning up test pod..."
kubectl delete pod subscription-integration-test -n bakery-ia --wait=false
echo "🏁 Integration test process completed!"
exit $RESULT

View File

@@ -102,6 +102,135 @@ async def register(
detail="Registration failed"
)
@router.post("/api/v1/auth/register-with-subscription", response_model=TokenResponse)
@track_execution_time("enhanced_registration_with_subscription_duration_seconds", "auth-service")
async def register_with_subscription(
user_data: UserRegistration,
request: Request,
auth_service: EnhancedAuthService = Depends(get_auth_service)
):
"""
Register new user and create subscription in one call
This endpoint implements the new registration flow where:
1. User is created
2. Payment customer is created via tenant service
3. Tenant-independent subscription is created via tenant service
4. Subscription data is stored in onboarding progress
5. User is authenticated and returned with tokens
The subscription will be linked to a tenant during the onboarding flow.
"""
metrics = get_metrics_collector(request)
logger.info("Registration with subscription attempt using new architecture",
email=user_data.email)
try:
# Enhanced input validation
if not user_data.email or not user_data.email.strip():
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Email is required"
)
if not user_data.password or len(user_data.password) < 8:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Password must be at least 8 characters long"
)
if not user_data.full_name or not user_data.full_name.strip():
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Full name is required"
)
# Step 1: Register user using enhanced service
logger.info("Step 1: Creating user", email=user_data.email)
result = await auth_service.register_user(user_data)
user_id = result.user.id
logger.info("User created successfully", user_id=user_id)
# Step 2: Create subscription via tenant service (if subscription data provided)
subscription_id = None
if user_data.subscription_plan and user_data.payment_method_id:
logger.info("Step 2: Creating tenant-independent subscription",
user_id=user_id,
plan=user_data.subscription_plan)
subscription_result = await auth_service.create_subscription_via_tenant_service(
user_id=user_id,
plan_id=user_data.subscription_plan,
payment_method_id=user_data.payment_method_id,
billing_cycle=user_data.billing_cycle or "monthly",
coupon_code=user_data.coupon_code
)
if subscription_result:
subscription_id = subscription_result.get("subscription_id")
logger.info("Tenant-independent subscription created successfully",
user_id=user_id,
subscription_id=subscription_id)
# Step 3: Store subscription data in onboarding progress
logger.info("Step 3: Storing subscription data in onboarding progress",
user_id=user_id)
# Update onboarding progress with subscription data
await auth_service.save_subscription_to_onboarding_progress(
user_id=user_id,
subscription_id=subscription_id,
registration_data=user_data
)
logger.info("Subscription data stored in onboarding progress",
user_id=user_id)
else:
logger.warning("Subscription creation failed, but user registration succeeded",
user_id=user_id)
else:
logger.info("No subscription data provided, skipping subscription creation",
user_id=user_id)
# Record successful registration
if metrics:
metrics.increment_counter("enhanced_registration_with_subscription_total", labels={"status": "success"})
logger.info("Registration with subscription completed successfully using new architecture",
user_id=user_id,
email=user_data.email,
subscription_id=subscription_id)
# Add subscription_id to the response
result.subscription_id = subscription_id
return result
except HTTPException as e:
if metrics:
error_type = "validation_error" if e.status_code == 400 else "conflict" if e.status_code == 409 else "failed"
metrics.increment_counter("enhanced_registration_with_subscription_total", labels={"status": error_type})
logger.warning("Registration with subscription failed using new architecture",
email=user_data.email,
error=e.detail)
raise
except Exception as e:
if metrics:
metrics.increment_counter("enhanced_registration_with_subscription_total", labels={"status": "error"})
logger.error("Registration with subscription system error using new architecture",
email=user_data.email,
error=str(e))
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Registration with subscription failed"
)
@router.post("/api/v1/auth/login", response_model=TokenResponse)
@track_execution_time("enhanced_login_duration_seconds", "auth-service")

View File

@@ -1044,4 +1044,110 @@ async def delete_step_draft(
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to delete step draft"
)
@router.get("/api/v1/auth/me/onboarding/subscription-parameters", response_model=Dict[str, Any])
async def get_subscription_parameters(
current_user: Dict[str, Any] = Depends(get_current_user_dep),
db: AsyncSession = Depends(get_db)
):
"""
Get subscription parameters saved during onboarding for tenant creation
Returns all parameters needed for subscription processing: plan, billing cycle, coupon, etc.
"""
try:
user_id = current_user["user_id"]
is_demo = current_user.get("is_demo", False)
# DEMO FIX: Demo users get default subscription parameters
if is_demo or user_id.startswith("demo-user-"):
logger.info(f"Demo user {user_id} requesting subscription parameters - returning demo defaults")
return {
"subscription_plan": "professional",
"billing_cycle": "monthly",
"coupon_code": "DEMO2025",
"payment_method_id": "pm_demo_test_123",
"payment_customer_id": "cus_demo_test_123", # Demo payment customer ID
"saved_at": datetime.now(timezone.utc).isoformat(),
"demo_mode": True
}
# Get subscription parameters from onboarding progress
from app.repositories.onboarding_repository import OnboardingRepository
onboarding_repo = OnboardingRepository(db)
subscription_params = await onboarding_repo.get_subscription_parameters(user_id)
if not subscription_params:
logger.warning(f"No subscription parameters found for user {user_id} - returning defaults")
return {
"subscription_plan": "starter",
"billing_cycle": "monthly",
"coupon_code": None,
"payment_method_id": None,
"payment_customer_id": None,
"saved_at": datetime.now(timezone.utc).isoformat()
}
logger.info(f"Retrieved subscription parameters for user {user_id}",
subscription_plan=subscription_params["subscription_plan"],
billing_cycle=subscription_params["billing_cycle"],
coupon_code=subscription_params["coupon_code"])
return subscription_params
except Exception as e:
logger.error(f"Error getting subscription parameters for user {current_user.get('user_id')}: {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to retrieve subscription parameters"
)
@router.get("/api/v1/auth/users/{user_id}/onboarding/subscription-parameters", response_model=Dict[str, Any])
async def get_user_subscription_parameters(
user_id: str,
current_user: Dict[str, Any] = Depends(get_current_user_dep),
db: AsyncSession = Depends(get_db)
):
"""
Get subscription parameters for a specific user (admin/service access)
"""
try:
# Check permissions - only admins and services can access other users' data
requester_id = current_user["user_id"]
requester_roles = current_user.get("roles", [])
is_service = current_user.get("is_service", False)
if not is_service and "super_admin" not in requester_roles and requester_id != user_id:
logger.warning(f"Unauthorized access attempt to user {user_id} subscription parameters by {requester_id}")
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Insufficient permissions to access other users' subscription parameters"
)
# Get subscription parameters from onboarding progress
from app.repositories.onboarding_repository import OnboardingRepository
onboarding_repo = OnboardingRepository(db)
subscription_params = await onboarding_repo.get_subscription_parameters(user_id)
if not subscription_params:
logger.warning(f"No subscription parameters found for user {user_id} - returning defaults")
return {
"subscription_plan": "starter",
"billing_cycle": "monthly",
"coupon_code": None,
"payment_method_id": None,
"payment_customer_id": None,
"saved_at": datetime.now(timezone.utc).isoformat()
}
logger.info(f"Retrieved subscription parameters for user {user_id} by {requester_id}",
subscription_plan=subscription_params["subscription_plan"])
return subscription_params
except Exception as e:
logger.error(f"Error getting subscription parameters for user {user_id}: {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to retrieve subscription parameters"
)

View File

@@ -2,7 +2,7 @@
User management API routes
"""
from fastapi import APIRouter, Depends, HTTPException, status, BackgroundTasks, Path
from fastapi import APIRouter, Depends, HTTPException, status, BackgroundTasks, Path, Body
from sqlalchemy.ext.asyncio import AsyncSession
from typing import Dict, Any
import structlog
@@ -223,7 +223,9 @@ async def get_user_by_id(
created_at=user.created_at,
last_login=user.last_login,
role=user.role,
tenant_id=None
tenant_id=None,
payment_customer_id=user.payment_customer_id,
default_payment_method_id=user.default_payment_method_id
)
except HTTPException:
@@ -481,3 +483,71 @@ async def get_user_activity(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to get user activity information"
)
@router.patch("/api/v1/auth/users/{user_id}/tenant")
async def update_user_tenant(
user_id: str = Path(..., description="User ID"),
tenant_data: Dict[str, Any] = Body(..., description="Tenant data containing tenant_id"),
db: AsyncSession = Depends(get_db)
):
"""
Update user's tenant_id after tenant registration
This endpoint is called by the tenant service after a user creates their tenant.
It links the user to their newly created tenant.
"""
try:
# Log the incoming request data for debugging
logger.debug("Received tenant update request",
user_id=user_id,
tenant_data=tenant_data)
tenant_id = tenant_data.get("tenant_id")
if not tenant_id:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="tenant_id is required"
)
logger.info("Updating user tenant_id",
user_id=user_id,
tenant_id=tenant_id)
user_service = UserService(db)
user = await user_service.get_user_by_id(uuid.UUID(user_id))
if not user:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="User not found"
)
# Update user's tenant_id
user.tenant_id = uuid.UUID(tenant_id)
user.updated_at = datetime.now(timezone.utc)
await db.commit()
await db.refresh(user)
logger.info("Successfully updated user tenant_id",
user_id=user_id,
tenant_id=tenant_id)
return {
"success": True,
"user_id": str(user.id),
"tenant_id": str(user.tenant_id)
}
except HTTPException:
raise
except Exception as e:
logger.error("Failed to update user tenant_id",
user_id=user_id,
error=str(e))
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to update user tenant_id"
)

View File

@@ -33,6 +33,10 @@ class User(Base):
timezone = Column(String(50), default="Europe/Madrid")
role = Column(String(20), nullable=False)
# Payment integration fields
payment_customer_id = Column(String(255), nullable=True, index=True)
default_payment_method_id = Column(String(255), nullable=True)
# REMOVED: All tenant relationships - these are handled by tenant service
# No tenant_memberships, tenants relationships

View File

@@ -199,9 +199,17 @@ class OnboardingRepository:
self,
user_id: str,
step_name: str,
step_data: Dict[str, Any]
step_data: Dict[str, Any],
auto_commit: bool = True
) -> UserOnboardingProgress:
"""Save data for a specific step without marking it as completed"""
"""Save data for a specific step without marking it as completed
Args:
user_id: User ID
step_name: Name of the step
step_data: Data to save
auto_commit: Whether to auto-commit (set to False when used within UnitOfWork)
"""
try:
# Get existing step or create new one
existing_step = await self.get_user_step(user_id, step_name)
@@ -221,7 +229,12 @@ class OnboardingRepository:
).returning(UserOnboardingProgress)
result = await self.db.execute(stmt)
await self.db.commit()
if auto_commit:
await self.db.commit()
else:
await self.db.flush()
return result.scalars().first()
else:
# Create new step with data but not completed
@@ -229,12 +242,14 @@ class OnboardingRepository:
user_id=user_id,
step_name=step_name,
completed=False,
step_data=step_data
step_data=step_data,
auto_commit=auto_commit
)
except Exception as e:
logger.error(f"Error saving step data for {step_name}, user {user_id}: {e}")
await self.db.rollback()
if auto_commit:
await self.db.rollback()
raise
async def get_step_data(self, user_id: str, step_name: str) -> Optional[Dict[str, Any]]:
@@ -246,6 +261,26 @@ class OnboardingRepository:
logger.error(f"Error getting step data for {step_name}, user {user_id}: {e}")
return None
async def get_subscription_parameters(self, user_id: str) -> Optional[Dict[str, Any]]:
"""Get subscription parameters saved during onboarding for tenant creation"""
try:
step_data = await self.get_step_data(user_id, "user_registered")
if step_data:
# Extract subscription-related parameters
subscription_params = {
"subscription_plan": step_data.get("subscription_plan", "starter"),
"billing_cycle": step_data.get("billing_cycle", "monthly"),
"coupon_code": step_data.get("coupon_code"),
"payment_method_id": step_data.get("payment_method_id"),
"payment_customer_id": step_data.get("payment_customer_id"),
"saved_at": step_data.get("saved_at")
}
return subscription_params
return None
except Exception as e:
logger.error(f"Error getting subscription parameters for user {user_id}: {e}")
return None
async def get_completion_stats(self) -> Dict[str, Any]:
"""Get completion statistics across all users"""
try:

View File

@@ -20,7 +20,8 @@ class UserRegistration(BaseModel):
tenant_name: Optional[str] = Field(None, max_length=255)
role: Optional[str] = Field("admin", pattern=r'^(user|admin|manager|super_admin)$')
subscription_plan: Optional[str] = Field("starter", description="Selected subscription plan (starter, professional, enterprise)")
use_trial: Optional[bool] = Field(False, description="Whether to use trial period")
billing_cycle: Optional[str] = Field("monthly", description="Billing cycle (monthly, yearly)")
coupon_code: Optional[str] = Field(None, description="Discount coupon code")
payment_method_id: Optional[str] = Field(None, description="Stripe payment method ID")
# GDPR Consent fields
terms_accepted: Optional[bool] = Field(True, description="Accept terms of service")
@@ -76,6 +77,7 @@ class TokenResponse(BaseModel):
token_type: str = "bearer"
expires_in: int = 3600 # seconds
user: Optional[UserData] = None
subscription_id: Optional[str] = Field(None, description="Subscription ID if created during registration")
class Config:
schema_extra = {
@@ -92,7 +94,8 @@ class TokenResponse(BaseModel):
"is_verified": False,
"created_at": "2025-07-22T10:00:00Z",
"role": "user"
}
},
"subscription_id": "sub_1234567890"
}
}
@@ -110,6 +113,8 @@ class UserResponse(BaseModel):
timezone: Optional[str] = None # ✅ Added missing field
tenant_id: Optional[str] = None
role: Optional[str] = "admin"
payment_customer_id: Optional[str] = None # ✅ Added payment integration field
default_payment_method_id: Optional[str] = None # ✅ Added payment integration field
class Config:
from_attributes = True # ✅ Enable ORM mode for SQLAlchemy objects

View File

@@ -21,6 +21,7 @@ from shared.database.unit_of_work import UnitOfWork
from shared.database.transactions import transactional
from shared.database.exceptions import DatabaseError, ValidationError, DuplicateRecordError
logger = structlog.get_logger()
@@ -169,9 +170,62 @@ class EnhancedAuthService:
# Re-raise to ensure registration fails if consent can't be recorded
raise
# Payment customer creation via tenant service
# The auth service calls the tenant service to create payment customer
# This maintains proper separation of concerns while providing seamless user experience
try:
# Call tenant service to create payment customer
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_for_tenant = {
"user_id": str(new_user.id),
"email": user_data.email,
"full_name": user_data.full_name,
"name": user_data.full_name
}
# Call tenant service to create payment customer
payment_result = await tenant_client.create_payment_customer(
user_data_for_tenant,
user_data.payment_method_id
)
if payment_result and payment_result.get("success"):
# Store payment customer ID from tenant service response
new_user.payment_customer_id = payment_result.get("payment_customer_id")
logger.info("Payment customer created successfully via tenant service",
user_id=new_user.id,
payment_customer_id=new_user.payment_customer_id,
payment_method_id=user_data.payment_method_id)
else:
logger.warning("Payment customer creation via tenant service returned no success",
user_id=new_user.id,
result=payment_result)
except Exception as e:
logger.error("Payment customer creation via tenant service failed",
user_id=new_user.id,
error=str(e))
# Don't fail registration if payment customer creation fails
# This allows users to register even if payment system is temporarily unavailable
new_user.payment_customer_id = None
# Store payment method ID if provided (will be used by tenant service)
if user_data.payment_method_id:
new_user.default_payment_method_id = user_data.payment_method_id
logger.info("Payment method ID stored for later use by tenant service",
user_id=new_user.id,
payment_method_id=user_data.payment_method_id)
# Store subscription plan selection in onboarding progress BEFORE committing
# This ensures it's part of the same transaction
if user_data.subscription_plan or user_data.use_trial or user_data.payment_method_id:
if user_data.subscription_plan or user_data.payment_method_id or user_data.billing_cycle or user_data.coupon_code:
try:
from app.repositories.onboarding_repository import OnboardingRepository
from app.models.onboarding import UserOnboardingProgress
@@ -181,8 +235,10 @@ class EnhancedAuthService:
plan_data = {
"subscription_plan": user_data.subscription_plan or "starter",
"subscription_tier": user_data.subscription_plan or "starter", # Store tier for enterprise onboarding logic
"use_trial": user_data.use_trial or False,
"billing_cycle": user_data.billing_cycle or "monthly",
"coupon_code": user_data.coupon_code,
"payment_method_id": user_data.payment_method_id,
"payment_customer_id": new_user.payment_customer_id, # Now created via tenant service
"saved_at": datetime.now(timezone.utc).isoformat()
}
@@ -197,11 +253,15 @@ class EnhancedAuthService:
auto_commit=False
)
logger.info("Subscription plan saved to onboarding progress",
logger.info("Subscription plan and parameters saved to onboarding progress",
user_id=new_user.id,
plan=user_data.subscription_plan)
plan=user_data.subscription_plan,
billing_cycle=user_data.billing_cycle,
coupon_code=user_data.coupon_code,
payment_method_id=user_data.payment_method_id,
payment_customer_id=new_user.payment_customer_id)
except Exception as e:
logger.error("Failed to save subscription plan to onboarding progress",
logger.error("Failed to save subscription plan and parameters to onboarding progress",
user_id=new_user.id,
error=str(e))
# Re-raise to ensure registration fails if onboarding data can't be saved
@@ -730,6 +790,177 @@ 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
) -> Optional[Dict[str, Any]]:
"""
Create a tenant-independent subscription via tenant service
This method calls the tenant service to create a subscription during user registration
that is not linked to any tenant. The subscription will be linked to a tenant
during the onboarding flow.
Args:
user_id: User ID
plan_id: Subscription plan ID
payment_method_id: Payment method ID
billing_cycle: Billing cycle (monthly/yearly)
coupon_code: Optional coupon code
Returns:
Dict with subscription creation results including:
- success: boolean
- subscription_id: string
- customer_id: string
- status: string
- plan: string
- billing_cycle: string
Returns None if creation fails
"""
try:
from shared.clients.tenant_client import TenantServiceClient
from shared.config.base import BaseServiceSettings
# Get the base settings to create tenant client
tenant_client = TenantServiceClient(BaseServiceSettings())
# Get user data for tenant service
user_data = await self.get_user_data_for_tenant_service(user_id)
logger.info("Creating tenant-independent subscription via tenant service",
user_id=user_id,
plan_id=plan_id)
# Call tenant service using the new dedicated method
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
)
if result:
logger.info("Tenant-independent subscription created successfully via tenant service",
user_id=user_id,
subscription_id=result.get('subscription_id'))
return result
else:
logger.error("Tenant-independent subscription creation failed via tenant service",
user_id=user_id)
return None
except Exception as e:
logger.error("Failed to create tenant-independent subscription via tenant service",
user_id=user_id,
error=str(e))
return None
async def get_user_data_for_tenant_service(self, user_id: str) -> Dict[str, Any]:
"""
Get user data formatted for tenant service calls
Args:
user_id: User ID
Returns:
Dict with user data including email, name, etc.
"""
try:
# Get user from database
async with self.database_manager.get_session() as db_session:
async with UnitOfWork(db_session) as uow:
user_repo = uow.register_repository("users", UserRepository, User)
user = await user_repo.get_by_id(user_id)
if not user:
raise ValueError(f"User {user_id} not found")
return {
"user_id": str(user.id),
"email": user.email,
"full_name": user.full_name,
"name": user.full_name
}
except Exception as e:
logger.error("Failed to get user data for 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: UserRegistration
) -> None:
"""
Save subscription data to the user's onboarding progress
This method stores subscription information in the onboarding progress
so it can be retrieved later during the tenant creation step.
Args:
user_id: User ID
subscription_id: Subscription ID created by tenant service
registration_data: Original registration data including plan, payment method, etc.
"""
try:
from app.repositories.onboarding_repository import OnboardingRepository
from app.models.onboarding import UserOnboardingProgress
# Prepare subscription data to store
subscription_data = {
"subscription_id": subscription_id,
"plan_id": registration_data.subscription_plan,
"payment_method_id": registration_data.payment_method_id,
"billing_cycle": registration_data.billing_cycle or "monthly",
"coupon_code": registration_data.coupon_code,
"created_at": datetime.now(timezone.utc).isoformat()
}
logger.info("Saving subscription data to onboarding progress",
user_id=user_id,
subscription_id=subscription_id)
# Save to onboarding progress
async with self.database_manager.get_session() as db_session:
async with UnitOfWork(db_session) as uow:
onboarding_repo = uow.register_repository(
"onboarding",
OnboardingRepository,
UserOnboardingProgress
)
# Save or update the subscription step data
await onboarding_repo.save_step_data(
user_id=user_id,
step_name="subscription",
step_data=subscription_data,
auto_commit=False
)
# Commit the transaction
await uow.commit()
logger.info("Subscription data saved successfully to onboarding progress",
user_id=user_id,
subscription_id=subscription_id)
except Exception as e:
logger.error("Failed to save subscription data to onboarding progress",
user_id=user_id,
subscription_id=subscription_id,
error=str(e))
# Don't raise - we don't want to fail the registration if this fails
# The subscription was already created, so the user can still proceed
# Legacy compatibility - alias EnhancedAuthService as AuthService
AuthService = EnhancedAuthService

View File

@@ -0,0 +1,41 @@
"""add_payment_columns_to_users
Revision ID: 20260113_add_payment_columns
Revises: 510cf1184e0b
Create Date: 2026-01-13 13:30:00.000000+00:00
Add payment_customer_id and default_payment_method_id columns to users table
to support payment integration.
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '20260113_add_payment_columns'
down_revision: Union[str, None] = '510cf1184e0b'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# Add payment_customer_id column
op.add_column('users',
sa.Column('payment_customer_id', sa.String(length=255), nullable=True))
# Add default_payment_method_id column
op.add_column('users',
sa.Column('default_payment_method_id', sa.String(length=255), nullable=True))
# Create index for payment_customer_id
op.create_index(op.f('ix_users_payment_customer_id'), 'users', ['payment_customer_id'], unique=False)
def downgrade() -> None:
# Drop index first
op.drop_index(op.f('ix_users_payment_customer_id'), table_name='users')
# Drop columns
op.drop_column('users', 'default_payment_method_id')
op.drop_column('users', 'payment_customer_id')

View File

@@ -2,10 +2,11 @@
Subscription management API for GDPR-compliant cancellation and reactivation
"""
from fastapi import APIRouter, Depends, HTTPException, status, Query, Path
from fastapi import APIRouter, Depends, HTTPException, status, Query, Path, Body
from pydantic import BaseModel, Field
from datetime import datetime, timezone, timedelta
from uuid import UUID
from typing import Optional, Dict, Any, List
import structlog
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
@@ -17,6 +18,8 @@ from app.core.database import get_db
from app.models.tenants import Subscription, Tenant
from app.services.subscription_limit_service import SubscriptionLimitService
from app.services.subscription_service import SubscriptionService
from app.services.subscription_orchestration_service import SubscriptionOrchestrationService
from app.services.payment_service import PaymentService
from shared.clients.stripe_client import StripeProvider
from app.core.config import settings
from shared.database.exceptions import DatabaseError, ValidationError
@@ -134,9 +137,9 @@ async def cancel_subscription(
5. Gateway enforces read-only mode for 'pending_cancellation' and 'inactive' statuses
"""
try:
# Use service layer instead of direct database access
subscription_service = SubscriptionService(db)
result = await subscription_service.cancel_subscription(
# Use orchestration service for complete workflow
orchestration_service = SubscriptionOrchestrationService(db)
result = await orchestration_service.orchestrate_subscription_cancellation(
request.tenant_id,
request.reason
)
@@ -195,9 +198,9 @@ async def reactivate_subscription(
- inactive (after effective date)
"""
try:
# Use service layer instead of direct database access
subscription_service = SubscriptionService(db)
result = await subscription_service.reactivate_subscription(
# Use orchestration service for complete workflow
orchestration_service = SubscriptionOrchestrationService(db)
result = await orchestration_service.orchestrate_subscription_reactivation(
request.tenant_id,
request.plan
)
@@ -296,9 +299,10 @@ async def get_tenant_invoices(
Get invoice history for a tenant from Stripe
"""
try:
# Use service layer instead of direct database access
# Use service layer for invoice retrieval
subscription_service = SubscriptionService(db)
invoices_data = await subscription_service.get_tenant_invoices(tenant_id)
payment_service = PaymentService()
invoices_data = await subscription_service.get_tenant_invoices(tenant_id, payment_service)
# Transform to response format
invoices = []
@@ -592,14 +596,25 @@ async def validate_plan_upgrade(
async def upgrade_subscription_plan(
tenant_id: str = Path(..., description="Tenant ID"),
new_plan: str = Query(..., description="New plan name"),
billing_cycle: Optional[str] = Query(None, description="Billing cycle (monthly/yearly)"),
immediate_change: bool = Query(True, description="Apply change immediately"),
current_user: dict = Depends(get_current_user_dep),
limit_service: SubscriptionLimitService = Depends(get_subscription_limit_service),
db: AsyncSession = Depends(get_db)
):
"""Upgrade subscription plan for a tenant"""
"""
Upgrade subscription plan for a tenant.
This endpoint:
1. Validates the upgrade is allowed
2. Calculates proration costs
3. Updates subscription in Stripe
4. Updates local database
5. Invalidates caches and tokens
"""
try:
# First validate the upgrade
# Step 1: Validate the upgrade
validation = await limit_service.validate_plan_upgrade(str(tenant_id), new_plan)
if not validation.get("can_upgrade", False):
raise HTTPException(
@@ -607,10 +622,8 @@ async def upgrade_subscription_plan(
detail=validation.get("reason", "Cannot upgrade to this plan")
)
# Use SubscriptionService for the upgrade
# Step 2: Get current subscription to determine billing cycle
subscription_service = SubscriptionService(db)
# Get current subscription
current_subscription = await subscription_service.get_subscription_by_tenant_id(tenant_id)
if not current_subscription:
raise HTTPException(
@@ -618,19 +631,23 @@ async def upgrade_subscription_plan(
detail="No active subscription found for this tenant"
)
# Update the subscription plan using service layer
# Note: This should be enhanced in SubscriptionService to handle plan upgrades
# For now, we'll use the repository directly but this should be moved to service layer
from app.repositories.subscription_repository import SubscriptionRepository
from app.models.tenants import Subscription as SubscriptionModel
subscription_repo = SubscriptionRepository(SubscriptionModel, db)
updated_subscription = await subscription_repo.update_subscription_plan(
str(current_subscription.id),
new_plan
# Use current billing cycle if not provided
if not billing_cycle:
billing_cycle = current_subscription.billing_interval or "monthly"
# Step 3: Use orchestration service for the upgrade
from app.services.subscription_orchestration_service import SubscriptionOrchestrationService
orchestration_service = SubscriptionOrchestrationService(db)
upgrade_result = await orchestration_service.orchestrate_plan_upgrade(
tenant_id=str(tenant_id),
new_plan=new_plan,
proration_behavior="create_prorations",
immediate_change=immediate_change,
billing_cycle=billing_cycle
)
# Invalidate subscription cache to ensure immediate availability of new tier
# Step 4: Invalidate subscription cache
try:
from app.services.subscription_cache import get_subscription_cache_service
import shared.redis_utils
@@ -647,8 +664,7 @@ async def upgrade_subscription_plan(
tenant_id=str(tenant_id),
error=str(cache_error))
# SECURITY: Invalidate all existing tokens for this tenant
# Forces users to re-authenticate and get new JWT with updated tier
# Step 5: Invalidate all existing tokens for this tenant
try:
redis_client = await get_redis_client()
if redis_client:
@@ -656,7 +672,7 @@ async def upgrade_subscription_plan(
await redis_client.set(
f"tenant:{tenant_id}:subscription_changed_at",
str(changed_timestamp),
ex=86400 # 24 hour TTL
ex=86400
)
logger.info("Set subscription change timestamp for token invalidation",
tenant_id=tenant_id,
@@ -666,7 +682,7 @@ async def upgrade_subscription_plan(
tenant_id=str(tenant_id),
error=str(token_error))
# Also publish event for real-time notification
# Step 6: Publish event for real-time notification
try:
from shared.messaging import UnifiedEventPublisher
event_publisher = UnifiedEventPublisher()
@@ -693,9 +709,9 @@ async def upgrade_subscription_plan(
"message": f"Plan successfully upgraded to {new_plan}",
"old_plan": current_subscription.plan,
"new_plan": new_plan,
"new_monthly_price": updated_subscription.monthly_price,
"proration_details": upgrade_result.get("proration_details"),
"validation": validation,
"requires_token_refresh": True # Signal to frontend
"requires_token_refresh": True
}
except HTTPException:
@@ -707,16 +723,130 @@ async def upgrade_subscription_plan(
error=str(e))
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to upgrade subscription plan"
detail=f"Failed to upgrade subscription plan: {str(e)}"
)
@router.post("/api/v1/subscriptions/{tenant_id}/change-billing-cycle")
async def change_billing_cycle(
tenant_id: str = Path(..., description="Tenant ID"),
new_billing_cycle: str = Query(..., description="New billing cycle (monthly/yearly)"),
current_user: dict = Depends(get_current_user_dep),
db: AsyncSession = Depends(get_db)
):
"""
Change billing cycle for a tenant's subscription.
This endpoint:
1. Validates the tenant has an active subscription
2. Calculates proration costs
3. Updates subscription in Stripe
4. Updates local database
5. Returns proration details to user
"""
try:
# Validate billing cycle parameter
if new_billing_cycle not in ["monthly", "yearly"]:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Billing cycle must be 'monthly' or 'yearly'"
)
# Get current subscription
subscription_service = SubscriptionService(db)
current_subscription = await subscription_service.get_subscription_by_tenant_id(tenant_id)
if not current_subscription:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="No active subscription found for this tenant"
)
# Check if already on requested billing cycle
current_cycle = current_subscription.billing_interval or "monthly"
if current_cycle == new_billing_cycle:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Subscription is already on {new_billing_cycle} billing"
)
# Use orchestration service for the billing cycle change
from app.services.subscription_orchestration_service import SubscriptionOrchestrationService
orchestration_service = SubscriptionOrchestrationService(db)
change_result = await orchestration_service.orchestrate_billing_cycle_change(
tenant_id=str(tenant_id),
new_billing_cycle=new_billing_cycle,
immediate_change=True
)
# Invalidate subscription cache
try:
from app.services.subscription_cache import get_subscription_cache_service
import shared.redis_utils
redis_client = await shared.redis_utils.initialize_redis(settings.REDIS_URL)
cache_service = get_subscription_cache_service(redis_client)
await cache_service.invalidate_subscription_cache(str(tenant_id))
logger.info("Subscription cache invalidated after billing cycle change",
tenant_id=str(tenant_id),
new_billing_cycle=new_billing_cycle)
except Exception as cache_error:
logger.error("Failed to invalidate subscription cache",
tenant_id=str(tenant_id),
error=str(cache_error))
# Publish event for real-time notification
try:
from shared.messaging import UnifiedEventPublisher
event_publisher = UnifiedEventPublisher()
await event_publisher.publish_business_event(
event_type="subscription.billing_cycle_changed",
tenant_id=str(tenant_id),
data={
"tenant_id": str(tenant_id),
"old_billing_cycle": current_cycle,
"new_billing_cycle": new_billing_cycle,
"action": "billing_cycle_change"
}
)
logger.info("Published billing cycle change event",
tenant_id=str(tenant_id))
except Exception as event_error:
logger.error("Failed to publish billing cycle change event",
tenant_id=str(tenant_id),
error=str(event_error))
return {
"success": True,
"message": f"Billing cycle changed to {new_billing_cycle}",
"old_billing_cycle": current_cycle,
"new_billing_cycle": new_billing_cycle,
"proration_details": change_result.get("proration_details")
}
except HTTPException:
raise
except Exception as e:
logger.error("Failed to change billing cycle",
tenant_id=str(tenant_id),
new_billing_cycle=new_billing_cycle,
error=str(e))
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to change billing cycle: {str(e)}"
)
@router.post("/api/v1/subscriptions/register-with-subscription")
async def register_with_subscription(
user_data: dict = Depends(get_current_user_dep),
plan_id: str = Query(..., description="Plan ID to subscribe to"),
plan_id: str = Query(..., description="Plan ID to subscribe to (starter, professional, enterprise)"),
payment_method_id: str = Query(..., description="Payment method ID from frontend"),
use_trial: bool = Query(False, description="Whether to use trial period for pilot users"),
coupon_code: Optional[str] = Query(None, description="Coupon code to apply (e.g., PILOT2025)"),
billing_interval: str = Query("monthly", description="Billing interval (monthly or yearly)"),
db: AsyncSession = Depends(get_db)
):
"""Process user registration with subscription creation"""
@@ -729,7 +859,9 @@ async def register_with_subscription(
user_data.get('tenant_id'),
plan_id,
payment_method_id,
14 if use_trial else None
None, # Trial period handled by coupon logic
billing_interval,
coupon_code # Pass coupon code for trial period determination
)
return {
@@ -745,6 +877,127 @@ async def register_with_subscription(
)
@router.post("/api/v1/subscriptions/{tenant_id}/create")
async def create_subscription_endpoint(
tenant_id: str = Path(..., description="Tenant ID"),
plan_id: str = Query(..., description="Plan ID (starter, professional, enterprise)"),
payment_method_id: str = Query(..., description="Payment method ID from frontend"),
billing_interval: str = Query("monthly", description="Billing interval (monthly or yearly)"),
trial_period_days: Optional[int] = Query(None, description="Trial period in days"),
coupon_code: Optional[str] = Query(None, description="Optional coupon code"),
current_user: dict = Depends(get_current_user_dep),
db: AsyncSession = Depends(get_db)
):
"""
Create a new subscription for a tenant using orchestration service
This endpoint orchestrates the complete subscription creation workflow
including payment provider integration and tenant updates.
"""
try:
# Prepare user data for orchestration service
user_data = {
'user_id': current_user.get('sub'),
'email': current_user.get('email'),
'full_name': current_user.get('name', 'Unknown User'),
'tenant_id': tenant_id
}
# Use orchestration service for complete workflow
orchestration_service = SubscriptionOrchestrationService(db)
result = await orchestration_service.orchestrate_subscription_creation(
tenant_id,
user_data,
plan_id,
payment_method_id,
billing_interval,
coupon_code
)
logger.info("subscription_created_via_orchestration",
tenant_id=tenant_id,
plan_id=plan_id,
billing_interval=billing_interval,
coupon_applied=result.get("coupon_applied", False))
return {
"success": True,
"message": "Subscription created successfully",
"data": result
}
except Exception as e:
logger.error("Failed to create subscription via API",
error=str(e),
tenant_id=tenant_id,
plan_id=plan_id)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to create subscription"
)
class CreateForRegistrationRequest(BaseModel):
"""Request model for create-for-registration endpoint"""
user_data: dict = Field(..., description="User data for subscription creation")
plan_id: str = Field(..., description="Plan ID (starter, professional, enterprise)")
payment_method_id: str = Field(..., description="Payment method ID from frontend")
billing_interval: str = Field("monthly", description="Billing interval (monthly or yearly)")
coupon_code: Optional[str] = Field(None, description="Optional coupon code")
@router.post("/api/v1/subscriptions/create-for-registration")
async def create_subscription_for_registration(
request: CreateForRegistrationRequest = Body(..., description="Subscription creation request"),
db: AsyncSession = Depends(get_db)
):
"""
Create a tenant-independent subscription during user registration
This endpoint creates a subscription that is not linked to any tenant.
The subscription will be linked to a tenant during the onboarding flow.
This is used during the new registration flow where users register
and pay before creating their tenant/bakery.
"""
try:
logger.info("Creating tenant-independent subscription for registration",
user_id=request.user_data.get('user_id'),
plan_id=request.plan_id)
# Use orchestration service for tenant-independent subscription creation
orchestration_service = SubscriptionOrchestrationService(db)
result = await orchestration_service.create_tenant_independent_subscription(
request.user_data,
request.plan_id,
request.payment_method_id,
request.billing_interval,
request.coupon_code
)
logger.info("Tenant-independent subscription created successfully",
user_id=request.user_data.get('user_id'),
subscription_id=result["subscription_id"],
plan_id=request.plan_id)
return {
"success": True,
"message": "Tenant-independent subscription created successfully",
"data": result
}
except Exception as e:
logger.error("Failed to create tenant-independent subscription",
error=str(e),
user_id=request.user_data.get('user_id'),
plan_id=request.plan_id)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to create tenant-independent subscription"
)
@router.post("/api/v1/subscriptions/{tenant_id}/update-payment-method")
async def update_payment_method(
tenant_id: str = Path(..., description="Tenant ID"),
@@ -813,3 +1066,314 @@ async def update_payment_method(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="An unexpected error occurred while updating payment method"
)
# ============================================================================
# NEW SUBSCRIPTION UPDATE ENDPOINTS WITH PRORATION SUPPORT
# ============================================================================
class SubscriptionChangePreviewRequest(BaseModel):
"""Request model for subscription change preview"""
new_plan: str = Field(..., description="New plan name (starter, professional, enterprise) or 'same' for billing cycle changes")
proration_behavior: str = Field("create_prorations", description="Proration behavior (create_prorations, none, always_invoice)")
billing_cycle: str = Field("monthly", description="Billing cycle for the new plan (monthly, yearly)")
class SubscriptionChangePreviewResponse(BaseModel):
"""Response model for subscription change preview"""
success: bool
current_plan: str
current_billing_cycle: str
current_price: float
new_plan: str
new_billing_cycle: str
new_price: float
proration_details: Dict[str, Any]
current_plan_features: List[str]
new_plan_features: List[str]
change_type: str
@router.post("/api/v1/subscriptions/{tenant_id}/preview-change", response_model=SubscriptionChangePreviewResponse)
async def preview_subscription_change(
tenant_id: str = Path(..., description="Tenant ID"),
request: SubscriptionChangePreviewRequest = Body(...),
current_user: dict = Depends(get_current_user_dep),
db: AsyncSession = Depends(get_db)
):
"""
Preview the cost impact of a subscription change
This endpoint allows users to see the proration details before confirming a subscription change.
It shows the cost difference, credits, and other financial impacts of changing plans or billing cycles.
"""
try:
# Use SubscriptionService for preview
subscription_service = SubscriptionService(db)
# Create payment service for proration calculation
payment_service = PaymentService()
result = await subscription_service.preview_subscription_change(
tenant_id,
request.new_plan,
request.proration_behavior,
request.billing_cycle,
payment_service
)
logger.info("subscription_change_previewed",
tenant_id=tenant_id,
user_id=current_user.get("user_id"),
new_plan=request.new_plan,
proration_amount=result["proration_details"].get("net_amount", 0))
return SubscriptionChangePreviewResponse(**result)
except ValidationError as ve:
logger.error("preview_subscription_change_validation_failed",
error=str(ve), tenant_id=tenant_id)
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=str(ve)
)
except DatabaseError as de:
logger.error("preview_subscription_change_failed",
error=str(de), tenant_id=tenant_id)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to preview subscription change"
)
except Exception as e:
logger.error("preview_subscription_change_unexpected_error",
error=str(e), tenant_id=tenant_id)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="An unexpected error occurred while previewing subscription change"
)
class SubscriptionPlanUpdateRequest(BaseModel):
"""Request model for subscription plan update"""
new_plan: str = Field(..., description="New plan name (starter, professional, enterprise)")
proration_behavior: str = Field("create_prorations", description="Proration behavior (create_prorations, none, always_invoice)")
immediate_change: bool = Field(False, description="Whether to apply changes immediately or at period end")
billing_cycle: str = Field("monthly", description="Billing cycle for the new plan (monthly, yearly)")
class SubscriptionPlanUpdateResponse(BaseModel):
"""Response model for subscription plan update"""
success: bool
message: str
old_plan: str
new_plan: str
proration_details: Dict[str, Any]
immediate_change: bool
new_status: str
new_period_end: str
@router.post("/api/v1/subscriptions/{tenant_id}/update-plan", response_model=SubscriptionPlanUpdateResponse)
async def update_subscription_plan(
tenant_id: str = Path(..., description="Tenant ID"),
request: SubscriptionPlanUpdateRequest = Body(...),
current_user: dict = Depends(get_current_user_dep),
db: AsyncSession = Depends(get_db)
):
"""
Update subscription plan with proration support
This endpoint allows users to change their subscription plan with proper proration handling.
It supports both immediate changes and changes that take effect at the end of the billing period.
"""
try:
# Use orchestration service for complete plan upgrade workflow
orchestration_service = SubscriptionOrchestrationService(db)
result = await orchestration_service.orchestrate_plan_upgrade(
tenant_id,
request.new_plan,
request.proration_behavior,
request.immediate_change,
request.billing_cycle
)
logger.info("subscription_plan_updated",
tenant_id=tenant_id,
user_id=current_user.get("user_id"),
old_plan=result["old_plan"],
new_plan=result["new_plan"],
proration_amount=result["proration_details"].get("net_amount", 0),
immediate_change=request.immediate_change)
return SubscriptionPlanUpdateResponse(**result)
except ValidationError as ve:
logger.error("update_subscription_plan_validation_failed",
error=str(ve), tenant_id=tenant_id)
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=str(ve)
)
except DatabaseError as de:
logger.error("update_subscription_plan_failed",
error=str(de), tenant_id=tenant_id)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to update subscription plan"
)
except Exception as e:
logger.error("update_subscription_plan_unexpected_error",
error=str(e), tenant_id=tenant_id)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="An unexpected error occurred while updating subscription plan"
)
class BillingCycleChangeRequest(BaseModel):
"""Request model for billing cycle change"""
new_billing_cycle: str = Field(..., description="New billing cycle (monthly, yearly)")
proration_behavior: str = Field("create_prorations", description="Proration behavior (create_prorations, none, always_invoice)")
class BillingCycleChangeResponse(BaseModel):
"""Response model for billing cycle change"""
success: bool
message: str
old_billing_cycle: str
new_billing_cycle: str
proration_details: Dict[str, Any]
new_status: str
new_period_end: str
@router.post("/api/v1/subscriptions/{tenant_id}/change-billing-cycle", response_model=BillingCycleChangeResponse)
async def change_billing_cycle(
tenant_id: str = Path(..., description="Tenant ID"),
request: BillingCycleChangeRequest = Body(...),
current_user: dict = Depends(get_current_user_dep),
db: AsyncSession = Depends(get_db)
):
"""
Change billing cycle (monthly ↔ yearly) for a subscription
This endpoint allows users to switch between monthly and yearly billing cycles.
It handles proration and creates appropriate charges or credits.
"""
try:
# Use orchestration service for complete billing cycle change workflow
orchestration_service = SubscriptionOrchestrationService(db)
result = await orchestration_service.orchestrate_billing_cycle_change(
tenant_id,
request.new_billing_cycle,
request.proration_behavior
)
logger.info("subscription_billing_cycle_changed",
tenant_id=tenant_id,
user_id=current_user.get("user_id"),
old_billing_cycle=result["old_billing_cycle"],
new_billing_cycle=result["new_billing_cycle"],
proration_amount=result["proration_details"].get("net_amount", 0))
return BillingCycleChangeResponse(**result)
except ValidationError as ve:
logger.error("change_billing_cycle_validation_failed",
error=str(ve), tenant_id=tenant_id)
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=str(ve)
)
except DatabaseError as de:
logger.error("change_billing_cycle_failed",
error=str(de), tenant_id=tenant_id)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to change billing cycle"
)
except Exception as e:
logger.error("change_billing_cycle_unexpected_error",
error=str(e), tenant_id=tenant_id)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="An unexpected error occurred while changing billing cycle"
)
# ============================================================================
# COUPON REDEMPTION ENDPOINTS
# ============================================================================
class CouponRedemptionRequest(BaseModel):
"""Request model for coupon redemption"""
coupon_code: str = Field(..., description="Coupon code to redeem")
base_trial_days: int = Field(14, description="Base trial days without coupon")
class CouponRedemptionResponse(BaseModel):
"""Response model for coupon redemption"""
success: bool
coupon_applied: bool
discount: Optional[Dict[str, Any]] = None
message: str
error: Optional[str] = None
@router.post("/api/v1/subscriptions/{tenant_id}/redeem-coupon", response_model=CouponRedemptionResponse)
async def redeem_coupon(
tenant_id: str = Path(..., description="Tenant ID"),
request: CouponRedemptionRequest = Body(...),
current_user: dict = Depends(get_current_user_dep),
db: AsyncSession = Depends(get_db)
):
"""
Redeem a coupon for a tenant
This endpoint handles the complete coupon redemption workflow including
validation, redemption, and tenant updates.
"""
try:
# Use orchestration service for complete coupon redemption workflow
orchestration_service = SubscriptionOrchestrationService(db)
result = await orchestration_service.orchestrate_coupon_redemption(
tenant_id,
request.coupon_code,
request.base_trial_days
)
logger.info("coupon_redeemed",
tenant_id=tenant_id,
user_id=current_user.get("user_id"),
coupon_code=request.coupon_code,
success=result["success"])
return CouponRedemptionResponse(
success=result["success"],
coupon_applied=result.get("coupon_applied", False),
discount=result.get("discount"),
message=result.get("message", "Coupon redemption processed"),
error=result.get("error")
)
except ValidationError as ve:
logger.error("coupon_redemption_validation_failed",
error=str(ve), tenant_id=tenant_id)
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=str(ve)
)
except DatabaseError as de:
logger.error("coupon_redemption_failed", error=str(de), tenant_id=tenant_id)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to redeem coupon"
)
except HTTPException:
raise
except Exception as e:
logger.error("coupon_redemption_failed", error=str(e), tenant_id=tenant_id)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="An unexpected error occurred while redeeming coupon"
)

View File

@@ -11,7 +11,8 @@ from app.schemas.tenants import (
ChildTenantCreate,
BulkChildTenantsCreate,
BulkChildTenantsResponse,
ChildTenantResponse
ChildTenantResponse,
TenantHierarchyResponse
)
from app.services.tenant_service import EnhancedTenantService
from app.repositories.tenant_repository import TenantRepository
@@ -219,6 +220,115 @@ async def get_tenant_children_count(
)
@router.get(route_builder.build_base_route("{tenant_id}/hierarchy", include_tenant_prefix=False), response_model=TenantHierarchyResponse)
@track_endpoint_metrics("tenant_hierarchy")
async def get_tenant_hierarchy(
tenant_id: UUID = Path(..., description="Tenant ID"),
current_user: Dict[str, Any] = Depends(get_current_user_dep),
tenant_service: EnhancedTenantService = Depends(get_enhanced_tenant_service)
):
"""
Get tenant hierarchy information.
Returns hierarchy metadata for a tenant including:
- Tenant type (standalone, parent, child)
- Parent tenant ID (if this is a child)
- Hierarchy path (materialized path)
- Number of child tenants (for parent tenants)
- Hierarchy level (depth in the tree)
This endpoint is used by the authentication layer for hierarchical access control
and by enterprise features for network management.
"""
try:
logger.info(
"Get tenant hierarchy request received",
tenant_id=str(tenant_id),
user_id=current_user.get("user_id"),
user_type=current_user.get("type", "user"),
is_service=current_user.get("type") == "service"
)
# Get tenant from database
from app.models.tenants import Tenant
async with tenant_service.database_manager.get_session() as session:
tenant_repo = TenantRepository(Tenant, session)
# Get the tenant
tenant = await tenant_repo.get(str(tenant_id))
if not tenant:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Tenant {tenant_id} not found"
)
# Skip access check for service-to-service calls
is_service_call = current_user.get("type") == "service"
if not is_service_call:
# Verify user has access to this tenant
access_info = await tenant_service.verify_user_access(current_user["user_id"], str(tenant_id))
if not access_info.has_access:
logger.warning(
"Access denied to tenant for hierarchy query",
tenant_id=str(tenant_id),
user_id=current_user.get("user_id")
)
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Access denied to tenant"
)
else:
logger.debug(
"Service-to-service call - bypassing access check",
service=current_user.get("service"),
tenant_id=str(tenant_id)
)
# Get child count if this is a parent tenant
child_count = 0
if tenant.tenant_type in ["parent", "standalone"]:
child_count = await tenant_repo.get_child_tenant_count(str(tenant_id))
# Calculate hierarchy level from hierarchy_path
hierarchy_level = 0
if tenant.hierarchy_path:
# hierarchy_path format: "parent_id" or "parent_id.child_id" or "parent_id.child_id.grandchild_id"
hierarchy_level = tenant.hierarchy_path.count('.')
# Build response
hierarchy_info = TenantHierarchyResponse(
tenant_id=str(tenant.id),
tenant_type=tenant.tenant_type or "standalone",
parent_tenant_id=str(tenant.parent_tenant_id) if tenant.parent_tenant_id else None,
hierarchy_path=tenant.hierarchy_path,
child_count=child_count,
hierarchy_level=hierarchy_level
)
logger.info(
"Get tenant hierarchy successful",
tenant_id=str(tenant_id),
tenant_type=tenant.tenant_type,
parent_tenant_id=str(tenant.parent_tenant_id) if tenant.parent_tenant_id else None,
child_count=child_count,
hierarchy_level=hierarchy_level
)
return hierarchy_info
except HTTPException:
raise
except Exception as e:
logger.error("Get tenant hierarchy failed",
tenant_id=str(tenant_id),
user_id=current_user.get("user_id"),
error=str(e))
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Get tenant hierarchy failed"
)
@router.post("/api/v1/tenants/{tenant_id}/bulk-children", response_model=BulkChildTenantsResponse)
@track_endpoint_metrics("bulk_create_child_tenants")
async def bulk_create_child_tenants(

View File

@@ -22,6 +22,8 @@ from shared.auth.decorators import (
get_current_user_dep,
require_admin_role_dep
)
from app.core.database import get_db
from sqlalchemy.ext.asyncio import AsyncSession
from shared.auth.access_control import owner_role_required, admin_role_required
from shared.routing.route_builder import RouteBuilder
from shared.database.base import create_database_manager
@@ -94,7 +96,6 @@ def get_payment_service():
logger.error("Failed to create payment service", error=str(e))
raise HTTPException(status_code=500, detail="Payment service initialization failed")
# ============================================================================
# TENANT REGISTRATION & ACCESS OPERATIONS
# ============================================================================
@@ -103,81 +104,142 @@ async def register_bakery(
bakery_data: BakeryRegistration,
current_user: Dict[str, Any] = Depends(get_current_user_dep),
tenant_service: EnhancedTenantService = Depends(get_enhanced_tenant_service),
payment_service: PaymentService = Depends(get_payment_service)
payment_service: PaymentService = Depends(get_payment_service),
db: AsyncSession = Depends(get_db)
):
"""Register a new bakery/tenant with enhanced validation and features"""
try:
# Validate coupon if provided
# Initialize variables to avoid UnboundLocalError
coupon_validation = None
if bakery_data.coupon_code:
from app.core.config import settings
database_manager = create_database_manager(settings.DATABASE_URL, "tenant-service")
success = None
discount = None
error = None
async with database_manager.get_session() as session:
# Temp tenant ID for validation (will be replaced with actual after creation)
temp_tenant_id = f"temp_{current_user['user_id']}"
coupon_validation = payment_service.validate_coupon_code(
bakery_data.coupon_code,
temp_tenant_id,
session
)
if not coupon_validation["valid"]:
logger.warning(
"Invalid coupon code provided during registration",
coupon_code=bakery_data.coupon_code,
error=coupon_validation["error_message"]
)
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=coupon_validation["error_message"]
)
# Create bakery/tenant
# Create bakery/tenant first
result = await tenant_service.create_bakery(
bakery_data,
current_user["user_id"]
)
# CRITICAL: Create default subscription for new tenant
try:
from app.repositories.subscription_repository import SubscriptionRepository
from app.models.tenants import Subscription
from datetime import datetime, timedelta, timezone
tenant_id = result.id
database_manager = create_database_manager(settings.DATABASE_URL, "tenant-service")
async with database_manager.get_session() as session:
subscription_repo = SubscriptionRepository(Subscription, session)
# NEW ARCHITECTURE: Check if we need to link an existing subscription
if bakery_data.link_existing_subscription and bakery_data.subscription_id:
logger.info("Linking existing subscription to new tenant",
tenant_id=tenant_id,
subscription_id=bakery_data.subscription_id,
user_id=current_user["user_id"])
# Create starter subscription with 14-day trial
trial_end_date = datetime.now(timezone.utc) + timedelta(days=14)
next_billing_date = trial_end_date
try:
# Import subscription service for linking
from app.services.subscription_service import SubscriptionService
await subscription_repo.create_subscription(
tenant_id=str(result.id),
plan="starter",
status="active",
billing_cycle="monthly",
next_billing_date=next_billing_date,
trial_ends_at=trial_end_date
subscription_service = SubscriptionService(db)
# Link the subscription to the tenant
linking_result = await subscription_service.link_subscription_to_tenant(
subscription_id=bakery_data.subscription_id,
tenant_id=tenant_id,
user_id=current_user["user_id"]
)
await session.commit()
logger.info(
"Default subscription created for new tenant",
tenant_id=str(result.id),
plan="starter",
trial_days=14
)
except Exception as subscription_error:
logger.error(
"Failed to create default subscription for tenant",
tenant_id=str(result.id),
error=str(subscription_error)
logger.info("Subscription linked successfully during tenant registration",
tenant_id=tenant_id,
subscription_id=bakery_data.subscription_id)
except Exception as linking_error:
logger.error("Error linking subscription during tenant registration",
tenant_id=tenant_id,
subscription_id=bakery_data.subscription_id,
error=str(linking_error))
# Don't fail tenant creation if subscription linking fails
# The subscription can be linked later manually
elif bakery_data.coupon_code:
# If no subscription but coupon provided, just validate and redeem coupon
coupon_validation = payment_service.validate_coupon_code(
bakery_data.coupon_code,
tenant_id,
db
)
# Don't fail tenant creation if subscription creation fails
if not coupon_validation["valid"]:
logger.warning(
"Invalid coupon code provided during registration",
coupon_code=bakery_data.coupon_code,
error=coupon_validation["error_message"]
)
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=coupon_validation["error_message"]
)
# Redeem coupon
success, discount, error = payment_service.redeem_coupon(
bakery_data.coupon_code,
tenant_id,
db
)
if success:
logger.info("Coupon redeemed during registration",
coupon_code=bakery_data.coupon_code,
tenant_id=tenant_id)
else:
logger.warning("Failed to redeem coupon during registration",
coupon_code=bakery_data.coupon_code,
error=error)
else:
# No subscription plan provided - check if tenant already has a subscription
# (from new registration flow where subscription is created first)
try:
from app.repositories.subscription_repository import SubscriptionRepository
from app.models.tenants import Subscription
from datetime import datetime, timedelta, timezone
from app.core.config import settings
database_manager = create_database_manager(settings.DATABASE_URL, "tenant-service")
async with database_manager.get_session() as session:
subscription_repo = SubscriptionRepository(Subscription, session)
# Check if tenant already has an active subscription
existing_subscription = await subscription_repo.get_by_tenant_id(str(result.id))
if existing_subscription:
logger.info(
"Tenant already has an active subscription, skipping default subscription creation",
tenant_id=str(result.id),
existing_plan=existing_subscription.plan,
subscription_id=str(existing_subscription.id)
)
else:
# Create starter subscription with 14-day trial
trial_end_date = datetime.now(timezone.utc) + timedelta(days=14)
next_billing_date = trial_end_date
await subscription_repo.create_subscription({
"tenant_id": str(result.id),
"plan": "starter",
"status": "trial",
"billing_cycle": "monthly",
"next_billing_date": next_billing_date,
"trial_ends_at": trial_end_date
})
await session.commit()
logger.info(
"Default free trial subscription created for new tenant",
tenant_id=str(result.id),
plan="starter",
trial_days=14
)
except Exception as subscription_error:
logger.error(
"Failed to create default subscription for tenant",
tenant_id=str(result.id),
error=str(subscription_error)
)
# If coupon was validated, redeem it now with actual tenant_id
if coupon_validation and coupon_validation["valid"]:
@@ -1068,9 +1130,101 @@ async def upgrade_subscription_plan(
@router.post(route_builder.build_base_route("subscriptions/register-with-subscription", include_tenant_prefix=False))
async def register_with_subscription(
user_data: Dict[str, Any],
plan_id: str = Query(..., description="Plan ID to subscribe to"),
plan_id: str = Query(..., description="Plan ID to subscribe to (starter, professional, enterprise)"),
payment_method_id: str = Query(..., description="Payment method ID from frontend"),
use_trial: bool = Query(False, description="Whether to use trial period for pilot users"),
coupon_code: str = Query(None, description="Coupon code for discounts or trial periods"),
billing_interval: str = Query("monthly", description="Billing interval (monthly or yearly)"),
payment_service: PaymentService = Depends(get_payment_service)
):
"""Process user registration with subscription creation"""
@router.post(route_builder.build_base_route("payment-customers/create", include_tenant_prefix=False))
async def create_payment_customer(
user_data: Dict[str, Any],
payment_method_id: Optional[str] = Query(None, description="Optional payment method ID"),
payment_service: PaymentService = Depends(get_payment_service)
):
"""
Create a payment customer in the payment provider
This endpoint is designed for service-to-service communication from auth service
during user registration. It creates a payment customer that can be used later
for subscription creation.
Args:
user_data: User data including email, name, etc.
payment_method_id: Optional payment method ID to attach
Returns:
Dictionary with payment customer details
"""
try:
logger.info("Creating payment customer via service-to-service call",
email=user_data.get('email'),
user_id=user_data.get('user_id'))
# Step 1: Create payment customer
customer = await payment_service.create_customer(user_data)
logger.info("Payment customer created successfully",
customer_id=customer.id,
email=customer.email)
# Step 2: Attach payment method if provided
payment_method_details = None
if payment_method_id:
try:
payment_method = await payment_service.update_payment_method(
customer.id,
payment_method_id
)
payment_method_details = {
"id": payment_method.id,
"type": payment_method.type,
"brand": payment_method.brand,
"last4": payment_method.last4,
"exp_month": payment_method.exp_month,
"exp_year": payment_method.exp_year
}
logger.info("Payment method attached to customer",
customer_id=customer.id,
payment_method_id=payment_method.id)
except Exception as e:
logger.warning("Failed to attach payment method to customer",
customer_id=customer.id,
error=str(e),
payment_method_id=payment_method_id)
# Continue without attached payment method
# Step 3: Return comprehensive result
return {
"success": True,
"payment_customer_id": customer.id,
"payment_method": payment_method_details,
"customer": {
"id": customer.id,
"email": customer.email,
"name": customer.name,
"created_at": customer.created_at.isoformat()
}
}
except Exception as e:
logger.error("Failed to create payment customer via service-to-service call",
error=str(e),
email=user_data.get('email'),
user_id=user_data.get('user_id'))
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to create payment customer: {str(e)}"
)
@router.post(route_builder.build_base_route("subscriptions/register-with-subscription", include_tenant_prefix=False))
async def register_with_subscription(
user_data: Dict[str, Any],
plan_id: str = Query(..., description="Plan ID to subscribe to (starter, professional, enterprise)"),
payment_method_id: str = Query(..., description="Payment method ID from frontend"),
coupon_code: str = Query(None, description="Coupon code for discounts or trial periods"),
billing_interval: str = Query("monthly", description="Billing interval (monthly or yearly)"),
payment_service: PaymentService = Depends(get_payment_service)
):
"""Process user registration with subscription creation"""
@@ -1080,7 +1234,8 @@ async def register_with_subscription(
user_data,
plan_id,
payment_method_id,
use_trial
coupon_code,
billing_interval
)
return {
@@ -1095,6 +1250,61 @@ async def register_with_subscription(
detail="Failed to register with subscription"
)
@router.post(route_builder.build_base_route("subscriptions/link", include_tenant_prefix=False))
async def link_subscription_to_tenant(
tenant_id: str = Query(..., description="Tenant ID to link subscription to"),
subscription_id: str = Query(..., description="Subscription ID to link"),
user_id: str = Query(..., description="User ID performing the linking"),
tenant_service: EnhancedTenantService = Depends(get_enhanced_tenant_service),
db: AsyncSession = Depends(get_db)
):
"""
Link a pending subscription to a tenant
This endpoint completes the registration flow by associating the subscription
created during registration with the tenant created during onboarding.
Args:
tenant_id: Tenant ID to link to
subscription_id: Subscription ID to link
user_id: User ID performing the linking (for validation)
Returns:
Dictionary with linking results
"""
try:
logger.info("Linking subscription to tenant",
tenant_id=tenant_id,
subscription_id=subscription_id,
user_id=user_id)
# Link subscription to tenant
result = await tenant_service.link_subscription_to_tenant(
tenant_id, subscription_id, user_id
)
logger.info("Subscription linked to tenant successfully",
tenant_id=tenant_id,
subscription_id=subscription_id,
user_id=user_id)
return {
"success": True,
"message": "Subscription linked to tenant successfully",
"data": result
}
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 HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to link subscription to tenant"
)
async def _invalidate_tenant_tokens(tenant_id: str, redis_client):
"""

View File

@@ -1,36 +1,37 @@
"""
Webhook endpoints for handling payment provider events
These endpoints receive events from payment providers like Stripe
All event processing is handled by SubscriptionOrchestrationService
"""
import structlog
import stripe
from fastapi import APIRouter, Depends, HTTPException, status, Request
from typing import Dict, Any
from datetime import datetime
from sqlalchemy.ext.asyncio import AsyncSession
from app.services.payment_service import PaymentService
from app.services.subscription_orchestration_service import SubscriptionOrchestrationService
from app.core.config import settings
from app.core.database import get_db
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from app.models.tenants import Subscription, Tenant
logger = structlog.get_logger()
router = APIRouter()
def get_payment_service():
def get_subscription_orchestration_service(
db: AsyncSession = Depends(get_db)
) -> SubscriptionOrchestrationService:
"""Dependency injection for SubscriptionOrchestrationService"""
try:
return PaymentService()
return SubscriptionOrchestrationService(db)
except Exception as e:
logger.error("Failed to create payment service", error=str(e))
raise HTTPException(status_code=500, detail="Payment service initialization failed")
logger.error("Failed to create subscription orchestration service", error=str(e))
raise HTTPException(status_code=500, detail="Service initialization failed")
@router.post("/webhooks/stripe")
async def stripe_webhook(
request: Request,
db: AsyncSession = Depends(get_db),
payment_service: PaymentService = Depends(get_payment_service)
orchestration_service: SubscriptionOrchestrationService = Depends(get_subscription_orchestration_service)
):
"""
Stripe webhook endpoint to handle payment events
@@ -74,39 +75,14 @@ async def stripe_webhook(
event_type=event_type,
event_id=event.get('id'))
# Process different types of events
if event_type == 'checkout.session.completed':
# Handle successful checkout
await handle_checkout_completed(event_data, db)
# Use orchestration service to handle the event
result = await orchestration_service.handle_payment_webhook(event_type, event_data)
elif event_type == 'customer.subscription.created':
# Handle new subscription
await handle_subscription_created(event_data, db)
logger.info("Webhook event processed via orchestration service",
event_type=event_type,
actions_taken=result.get("actions_taken", []))
elif event_type == 'customer.subscription.updated':
# Handle subscription update
await handle_subscription_updated(event_data, db)
elif event_type == 'customer.subscription.deleted':
# Handle subscription cancellation
await handle_subscription_deleted(event_data, db)
elif event_type == 'invoice.payment_succeeded':
# Handle successful payment
await handle_payment_succeeded(event_data, db)
elif event_type == 'invoice.payment_failed':
# Handle failed payment
await handle_payment_failed(event_data, db)
elif event_type == 'customer.subscription.trial_will_end':
# Handle trial ending soon (3 days before)
await handle_trial_will_end(event_data, db)
else:
logger.info("Unhandled webhook event type", event_type=event_type)
return {"success": True, "event_type": event_type}
return {"success": True, "event_type": event_type, "actions_taken": result.get("actions_taken", [])}
except HTTPException:
raise
@@ -116,260 +92,3 @@ async def stripe_webhook(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Webhook processing error"
)
async def handle_checkout_completed(session: Dict[str, Any], db: AsyncSession):
"""Handle successful checkout session completion"""
logger.info("Processing checkout.session.completed",
session_id=session.get('id'))
customer_id = session.get('customer')
subscription_id = session.get('subscription')
if customer_id and subscription_id:
# Update tenant with subscription info
query = select(Tenant).where(Tenant.stripe_customer_id == customer_id)
result = await db.execute(query)
tenant = result.scalar_one_or_none()
if tenant:
logger.info("Checkout completed for tenant",
tenant_id=str(tenant.id),
subscription_id=subscription_id)
async def handle_subscription_created(subscription: Dict[str, Any], db: AsyncSession):
"""Handle new subscription creation"""
logger.info("Processing customer.subscription.created",
subscription_id=subscription.get('id'))
customer_id = subscription.get('customer')
subscription_id = subscription.get('id')
status_value = subscription.get('status')
# Find tenant by customer ID
query = select(Tenant).where(Tenant.stripe_customer_id == customer_id)
result = await db.execute(query)
tenant = result.scalar_one_or_none()
if tenant:
logger.info("Subscription created for tenant",
tenant_id=str(tenant.id),
subscription_id=subscription_id,
status=status_value)
async def handle_subscription_updated(subscription: Dict[str, Any], db: AsyncSession):
"""Handle subscription updates (status changes, plan changes, etc.)"""
subscription_id = subscription.get('id')
status_value = subscription.get('status')
logger.info("Processing customer.subscription.updated",
subscription_id=subscription_id,
new_status=status_value)
# Find subscription in database
query = select(Subscription).where(Subscription.subscription_id == subscription_id)
result = await db.execute(query)
db_subscription = result.scalar_one_or_none()
if db_subscription:
# Update subscription status
db_subscription.status = status_value
db_subscription.current_period_end = datetime.fromtimestamp(
subscription.get('current_period_end')
)
# Update active status based on Stripe status
if status_value == 'active':
db_subscription.is_active = True
elif status_value in ['canceled', 'past_due', 'unpaid']:
db_subscription.is_active = False
await db.commit()
# Invalidate cache
try:
from app.services.subscription_cache import get_subscription_cache_service
import shared.redis_utils
redis_client = await shared.redis_utils.initialize_redis(settings.REDIS_URL)
cache_service = get_subscription_cache_service(redis_client)
await cache_service.invalidate_subscription_cache(str(db_subscription.tenant_id))
except Exception as cache_error:
logger.error("Failed to invalidate cache", error=str(cache_error))
logger.info("Subscription updated in database",
subscription_id=subscription_id,
tenant_id=str(db_subscription.tenant_id))
async def handle_subscription_deleted(subscription: Dict[str, Any], db: AsyncSession):
"""Handle subscription cancellation/deletion"""
subscription_id = subscription.get('id')
logger.info("Processing customer.subscription.deleted",
subscription_id=subscription_id)
# Find subscription in database
query = select(Subscription).where(Subscription.subscription_id == subscription_id)
result = await db.execute(query)
db_subscription = result.scalar_one_or_none()
if db_subscription:
db_subscription.status = 'canceled'
db_subscription.is_active = False
db_subscription.canceled_at = datetime.utcnow()
await db.commit()
# Invalidate cache
try:
from app.services.subscription_cache import get_subscription_cache_service
import shared.redis_utils
redis_client = await shared.redis_utils.initialize_redis(settings.REDIS_URL)
cache_service = get_subscription_cache_service(redis_client)
await cache_service.invalidate_subscription_cache(str(db_subscription.tenant_id))
except Exception as cache_error:
logger.error("Failed to invalidate cache", error=str(cache_error))
logger.info("Subscription canceled in database",
subscription_id=subscription_id,
tenant_id=str(db_subscription.tenant_id))
async def handle_payment_succeeded(invoice: Dict[str, Any], db: AsyncSession):
"""Handle successful invoice payment"""
invoice_id = invoice.get('id')
subscription_id = invoice.get('subscription')
logger.info("Processing invoice.payment_succeeded",
invoice_id=invoice_id,
subscription_id=subscription_id)
if subscription_id:
# Find subscription and ensure it's active
query = select(Subscription).where(Subscription.subscription_id == subscription_id)
result = await db.execute(query)
db_subscription = result.scalar_one_or_none()
if db_subscription:
db_subscription.status = 'active'
db_subscription.is_active = True
await db.commit()
logger.info("Payment succeeded, subscription activated",
subscription_id=subscription_id,
tenant_id=str(db_subscription.tenant_id))
async def handle_payment_failed(invoice: Dict[str, Any], db: AsyncSession):
"""Handle failed invoice payment"""
invoice_id = invoice.get('id')
subscription_id = invoice.get('subscription')
customer_id = invoice.get('customer')
logger.error("Processing invoice.payment_failed",
invoice_id=invoice_id,
subscription_id=subscription_id,
customer_id=customer_id)
if subscription_id:
# Find subscription and mark as past_due
query = select(Subscription).where(Subscription.subscription_id == subscription_id)
result = await db.execute(query)
db_subscription = result.scalar_one_or_none()
if db_subscription:
db_subscription.status = 'past_due'
db_subscription.is_active = False
await db.commit()
logger.warning("Payment failed, subscription marked past_due",
subscription_id=subscription_id,
tenant_id=str(db_subscription.tenant_id))
# TODO: Send notification to user about payment failure
# You can integrate with your notification service here
async def handle_trial_will_end(subscription: Dict[str, Any], db: AsyncSession):
"""Handle notification that trial will end in 3 days"""
subscription_id = subscription.get('id')
trial_end = subscription.get('trial_end')
logger.info("Processing customer.subscription.trial_will_end",
subscription_id=subscription_id,
trial_end_timestamp=trial_end)
# Find subscription
query = select(Subscription).where(Subscription.subscription_id == subscription_id)
result = await db.execute(query)
db_subscription = result.scalar_one_or_none()
if db_subscription:
logger.info("Trial ending soon for subscription",
subscription_id=subscription_id,
tenant_id=str(db_subscription.tenant_id))
# TODO: Send notification to user about trial ending soon
# You can integrate with your notification service here
@router.post("/webhooks/generic")
async def generic_webhook(
request: Request,
payment_service: PaymentService = Depends(get_payment_service)
):
"""
Generic webhook endpoint that can handle events from any payment provider
"""
try:
# Get the payload
payload = await request.json()
# Log the event for debugging
logger.info("Received generic webhook", payload=payload)
# Process the event based on its type
event_type = payload.get('type', 'unknown')
event_data = payload.get('data', {})
# Process different types of events
if event_type == 'subscription.created':
# Handle new subscription
logger.info("Processing new subscription event", subscription_id=event_data.get('id'))
# Update database with new subscription
elif event_type == 'subscription.updated':
# Handle subscription update
logger.info("Processing subscription update event", subscription_id=event_data.get('id'))
# Update database with subscription changes
elif event_type == 'subscription.deleted':
# Handle subscription cancellation
logger.info("Processing subscription cancellation event", subscription_id=event_data.get('id'))
# Update database with cancellation
elif event_type == 'payment.succeeded':
# Handle successful payment
logger.info("Processing successful payment event", payment_id=event_data.get('id'))
# Update payment status in database
elif event_type == 'payment.failed':
# Handle failed payment
logger.info("Processing failed payment event", payment_id=event_data.get('id'))
# Update payment status and notify user
elif event_type == 'invoice.created':
# Handle new invoice
logger.info("Processing new invoice event", invoice_id=event_data.get('id'))
# Store invoice information
else:
logger.warning("Unknown event type received", event_type=event_type)
return {"success": True}
except Exception as e:
logger.error("Error processing generic webhook", error=str(e))
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Webhook error"
)

View File

@@ -10,6 +10,7 @@ Multi-tenant management and subscription handling
from shared.config.base import BaseServiceSettings
import os
from typing import Dict, Tuple, ClassVar
class TenantSettings(BaseServiceSettings):
"""Tenant service specific settings"""
@@ -66,6 +67,17 @@ class TenantSettings(BaseServiceSettings):
BILLING_CURRENCY: str = os.getenv("BILLING_CURRENCY", "EUR")
BILLING_CYCLE_DAYS: int = int(os.getenv("BILLING_CYCLE_DAYS", "30"))
# Stripe Proration Configuration
DEFAULT_PRORATION_BEHAVIOR: str = os.getenv("DEFAULT_PRORATION_BEHAVIOR", "create_prorations")
UPGRADE_PRORATION_BEHAVIOR: str = os.getenv("UPGRADE_PRORATION_BEHAVIOR", "create_prorations")
DOWNGRADE_PRORATION_BEHAVIOR: str = os.getenv("DOWNGRADE_PRORATION_BEHAVIOR", "none")
BILLING_CYCLE_CHANGE_PRORATION: str = os.getenv("BILLING_CYCLE_CHANGE_PRORATION", "create_prorations")
# Stripe Subscription Update Settings
STRIPE_BILLING_CYCLE_ANCHOR: str = os.getenv("STRIPE_BILLING_CYCLE_ANCHOR", "unchanged")
STRIPE_PAYMENT_BEHAVIOR: str = os.getenv("STRIPE_PAYMENT_BEHAVIOR", "error_if_incomplete")
ALLOW_IMMEDIATE_SUBSCRIPTION_CHANGES: bool = os.getenv("ALLOW_IMMEDIATE_SUBSCRIPTION_CHANGES", "true").lower() == "true"
# Resource Limits
MAX_API_CALLS_PER_MINUTE: int = int(os.getenv("MAX_API_CALLS_PER_MINUTE", "100"))
MAX_STORAGE_MB: int = int(os.getenv("MAX_STORAGE_MB", "1024"))
@@ -89,6 +101,24 @@ class TenantSettings(BaseServiceSettings):
STRIPE_PUBLISHABLE_KEY: str = os.getenv("STRIPE_PUBLISHABLE_KEY", "")
STRIPE_SECRET_KEY: str = os.getenv("STRIPE_SECRET_KEY", "")
STRIPE_WEBHOOK_SECRET: str = os.getenv("STRIPE_WEBHOOK_SECRET", "")
# Stripe Price IDs for subscription plans
STARTER_MONTHLY_PRICE_ID: str = os.getenv("STARTER_MONTHLY_PRICE_ID", "price_1Sp0p3IzCdnBmAVT2Gs7z5np")
STARTER_YEARLY_PRICE_ID: str = os.getenv("STARTER_YEARLY_PRICE_ID", "price_1Sp0twIzCdnBmAVTD1lNLedx")
PROFESSIONAL_MONTHLY_PRICE_ID: str = os.getenv("PROFESSIONAL_MONTHLY_PRICE_ID", "price_1Sp0w7IzCdnBmAVTp0Jxhh1u")
PROFESSIONAL_YEARLY_PRICE_ID: str = os.getenv("PROFESSIONAL_YEARLY_PRICE_ID", "price_1Sp0yAIzCdnBmAVTLoGl4QCb")
ENTERPRISE_MONTHLY_PRICE_ID: str = os.getenv("ENTERPRISE_MONTHLY_PRICE_ID", "price_1Sp0zAIzCdnBmAVTXpApF7YO")
ENTERPRISE_YEARLY_PRICE_ID: str = os.getenv("ENTERPRISE_YEARLY_PRICE_ID", "price_1Sp15mIzCdnBmAVTuxffMpV5")
# Price ID mapping for easy lookup
STRIPE_PRICE_ID_MAPPING: ClassVar[Dict[Tuple[str, str], str]] = {
('starter', 'monthly'): STARTER_MONTHLY_PRICE_ID,
('starter', 'yearly'): STARTER_YEARLY_PRICE_ID,
('professional', 'monthly'): PROFESSIONAL_MONTHLY_PRICE_ID,
('professional', 'yearly'): PROFESSIONAL_YEARLY_PRICE_ID,
('enterprise', 'monthly'): ENTERPRISE_MONTHLY_PRICE_ID,
('enterprise', 'yearly'): ENTERPRISE_YEARLY_PRICE_ID,
}
# ============================================================
# SCHEDULER CONFIGURATION

View File

@@ -147,14 +147,22 @@ class TenantMember(Base):
# Additional models for subscriptions, plans, etc.
class Subscription(Base):
"""Subscription model for tenant billing"""
"""Subscription model for tenant billing with tenant linking support"""
__tablename__ = "subscriptions"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
tenant_id = Column(UUID(as_uuid=True), ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False)
tenant_id = Column(UUID(as_uuid=True), ForeignKey("tenants.id", ondelete="CASCADE"), nullable=True)
# User reference for tenant-independent subscriptions
user_id = Column(UUID(as_uuid=True), nullable=True, index=True)
# Tenant linking status
is_tenant_linked = Column(Boolean, default=False, nullable=False)
tenant_linking_status = Column(String(50), nullable=True) # pending, completed, failed
linked_at = Column(DateTime(timezone=True), nullable=True)
plan = Column(String(50), default="starter") # starter, professional, enterprise
status = Column(String(50), default="active") # active, pending_cancellation, inactive, suspended
status = Column(String(50), default="active") # active, pending_cancellation, inactive, suspended, pending_tenant_linking
# Billing
monthly_price = Column(Float, default=0.0)
@@ -182,4 +190,14 @@ class Subscription(Base):
tenant = relationship("Tenant")
def __repr__(self):
return f"<Subscription(tenant_id={self.tenant_id}, plan={self.plan}, status={self.status})>"
return f"<Subscription(id={self.id}, tenant_id={self.tenant_id}, user_id={self.user_id}, plan={self.plan}, status={self.status})>"
def is_pending_tenant_linking(self) -> bool:
"""Check if subscription is waiting to be linked to a tenant"""
return self.tenant_linking_status == "pending" and not self.is_tenant_linked
def can_be_linked_to_tenant(self, user_id: str) -> bool:
"""Check if subscription can be linked to a tenant by the given user"""
return (self.is_pending_tenant_linking() and
str(self.user_id) == user_id and
self.tenant_id is None)

View File

@@ -1,10 +1,11 @@
"""
Repository for coupon data access and validation
"""
from datetime import datetime
from datetime import datetime, timezone
from typing import Optional
from sqlalchemy.orm import Session
from sqlalchemy import and_
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import and_, select
from sqlalchemy.orm import selectinload
from app.models.coupon import CouponModel, CouponRedemptionModel
from shared.subscription.coupons import (
@@ -20,24 +21,25 @@ from shared.subscription.coupons import (
class CouponRepository:
"""Data access layer for coupon operations"""
def __init__(self, db: Session):
def __init__(self, db: AsyncSession):
self.db = db
def get_coupon_by_code(self, code: str) -> Optional[Coupon]:
async def get_coupon_by_code(self, code: str) -> Optional[Coupon]:
"""
Retrieve coupon by code.
Returns None if not found.
"""
coupon_model = self.db.query(CouponModel).filter(
CouponModel.code == code.upper()
).first()
result = await self.db.execute(
select(CouponModel).where(CouponModel.code == code.upper())
)
coupon_model = result.scalar_one_or_none()
if not coupon_model:
return None
return self._model_to_dataclass(coupon_model)
def validate_coupon(
async def validate_coupon(
self,
code: str,
tenant_id: str
@@ -47,7 +49,7 @@ class CouponRepository:
Checks: existence, validity, redemption limits, and if tenant already used it.
"""
# Get coupon
coupon = self.get_coupon_by_code(code)
coupon = await self.get_coupon_by_code(code)
if not coupon:
return CouponValidationResult(
valid=False,
@@ -73,12 +75,15 @@ class CouponRepository:
)
# Check if tenant already redeemed this coupon
existing_redemption = self.db.query(CouponRedemptionModel).filter(
and_(
CouponRedemptionModel.tenant_id == tenant_id,
CouponRedemptionModel.coupon_code == code.upper()
result = await self.db.execute(
select(CouponRedemptionModel).where(
and_(
CouponRedemptionModel.tenant_id == tenant_id,
CouponRedemptionModel.coupon_code == code.upper()
)
)
).first()
)
existing_redemption = result.scalar_one_or_none()
if existing_redemption:
return CouponValidationResult(
@@ -98,22 +103,40 @@ class CouponRepository:
discount_preview=discount_preview
)
def redeem_coupon(
async def redeem_coupon(
self,
code: str,
tenant_id: str,
tenant_id: Optional[str],
base_trial_days: int = 14
) -> tuple[bool, Optional[CouponRedemption], Optional[str]]:
"""
Redeem a coupon for a tenant.
For tenant-independent registrations, tenant_id can be None initially.
Returns (success, redemption, error_message)
"""
# Validate first
validation = self.validate_coupon(code, tenant_id)
if not validation.valid:
return False, None, validation.error_message
# For tenant-independent registrations, skip tenant validation
if tenant_id:
# Validate first
validation = await self.validate_coupon(code, tenant_id)
if not validation.valid:
return False, None, validation.error_message
coupon = validation.coupon
else:
# Just get the coupon and validate its general availability
coupon = await self.get_coupon_by_code(code)
if not coupon:
return False, None, "Código de cupón inválido"
coupon = validation.coupon
# Check if coupon can be redeemed
can_redeem, reason = coupon.can_be_redeemed()
if not can_redeem:
error_messages = {
"Coupon is inactive": "Este cupón no está activo",
"Coupon is not yet valid": "Este cupón aún no es válido",
"Coupon has expired": "Este cupón ha expirado",
"Coupon has reached maximum redemptions": "Este cupón ha alcanzado su límite de usos"
}
return False, None, error_messages.get(reason, reason)
# Calculate discount applied
discount_applied = self._calculate_discount_applied(
@@ -121,58 +144,80 @@ class CouponRepository:
base_trial_days
)
# Create redemption record
redemption_model = CouponRedemptionModel(
tenant_id=tenant_id,
coupon_code=code.upper(),
redeemed_at=datetime.utcnow(),
discount_applied=discount_applied,
extra_data={
"coupon_type": coupon.discount_type.value,
"coupon_value": coupon.discount_value
}
)
self.db.add(redemption_model)
# Increment coupon redemption count
coupon_model = self.db.query(CouponModel).filter(
CouponModel.code == code.upper()
).first()
if coupon_model:
coupon_model.current_redemptions += 1
try:
self.db.commit()
self.db.refresh(redemption_model)
redemption = CouponRedemption(
id=str(redemption_model.id),
tenant_id=redemption_model.tenant_id,
coupon_code=redemption_model.coupon_code,
redeemed_at=redemption_model.redeemed_at,
discount_applied=redemption_model.discount_applied,
extra_data=redemption_model.extra_data
# Only create redemption record if tenant_id is provided
# For tenant-independent subscriptions, skip redemption record creation
if tenant_id:
# Create redemption record
redemption_model = CouponRedemptionModel(
tenant_id=tenant_id,
coupon_code=code.upper(),
redeemed_at=datetime.now(timezone.utc),
discount_applied=discount_applied,
extra_data={
"coupon_type": coupon.discount_type.value,
"coupon_value": coupon.discount_value
}
)
self.db.add(redemption_model)
# Increment coupon redemption count
result = await self.db.execute(
select(CouponModel).where(CouponModel.code == code.upper())
)
coupon_model = result.scalar_one_or_none()
if coupon_model:
coupon_model.current_redemptions += 1
try:
await self.db.commit()
await self.db.refresh(redemption_model)
redemption = CouponRedemption(
id=str(redemption_model.id),
tenant_id=redemption_model.tenant_id,
coupon_code=redemption_model.coupon_code,
redeemed_at=redemption_model.redeemed_at,
discount_applied=redemption_model.discount_applied,
extra_data=redemption_model.extra_data
)
return True, redemption, None
except Exception as e:
await self.db.rollback()
return False, None, f"Error al aplicar el cupón: {str(e)}"
else:
# For tenant-independent subscriptions, return discount without creating redemption
# The redemption will be created when the tenant is linked
redemption = CouponRedemption(
id="pending", # Temporary ID
tenant_id="pending", # Will be set during tenant linking
coupon_code=code.upper(),
redeemed_at=datetime.now(timezone.utc),
discount_applied=discount_applied,
extra_data={
"coupon_type": coupon.discount_type.value,
"coupon_value": coupon.discount_value
}
)
return True, redemption, None
except Exception as e:
self.db.rollback()
return False, None, f"Error al aplicar el cupón: {str(e)}"
def get_redemption_by_tenant_and_code(
async def get_redemption_by_tenant_and_code(
self,
tenant_id: str,
code: str
) -> Optional[CouponRedemption]:
"""Get existing redemption for tenant and coupon code"""
redemption_model = self.db.query(CouponRedemptionModel).filter(
and_(
CouponRedemptionModel.tenant_id == tenant_id,
CouponRedemptionModel.coupon_code == code.upper()
result = await self.db.execute(
select(CouponRedemptionModel).where(
and_(
CouponRedemptionModel.tenant_id == tenant_id,
CouponRedemptionModel.coupon_code == code.upper()
)
)
).first()
)
redemption_model = result.scalar_one_or_none()
if not redemption_model:
return None
@@ -186,18 +231,22 @@ class CouponRepository:
extra_data=redemption_model.extra_data
)
def get_coupon_usage_stats(self, code: str) -> Optional[dict]:
async def get_coupon_usage_stats(self, code: str) -> Optional[dict]:
"""Get usage statistics for a coupon"""
coupon_model = self.db.query(CouponModel).filter(
CouponModel.code == code.upper()
).first()
result = await self.db.execute(
select(CouponModel).where(CouponModel.code == code.upper())
)
coupon_model = result.scalar_one_or_none()
if not coupon_model:
return None
redemptions_count = self.db.query(CouponRedemptionModel).filter(
CouponRedemptionModel.coupon_code == code.upper()
).count()
count_result = await self.db.execute(
select(CouponRedemptionModel).where(
CouponRedemptionModel.coupon_code == code.upper()
)
)
redemptions_count = len(count_result.scalars().all())
return {
"code": coupon_model.code,

View File

@@ -502,3 +502,201 @@ class SubscriptionRepository(TenantBaseRepository):
except Exception as e:
logger.warning("Failed to invalidate cache (non-critical)",
tenant_id=tenant_id, error=str(e))
# ========================================================================
# TENANT-INDEPENDENT SUBSCRIPTION METHODS (New Architecture)
# ========================================================================
async def create_tenant_independent_subscription(
self,
subscription_data: Dict[str, Any]
) -> Subscription:
"""Create a subscription not linked to any tenant (for registration flow)"""
try:
# Validate required data for tenant-independent subscription
required_fields = ["user_id", "plan", "stripe_subscription_id", "stripe_customer_id"]
validation_result = self._validate_tenant_data(subscription_data, required_fields)
if not validation_result["is_valid"]:
raise ValidationError(f"Invalid subscription data: {validation_result['errors']}")
# Ensure tenant_id is not provided (this is tenant-independent)
if "tenant_id" in subscription_data and subscription_data["tenant_id"]:
raise ValidationError("tenant_id should not be provided for tenant-independent subscriptions")
# Set tenant-independent specific fields
subscription_data["tenant_id"] = None
subscription_data["is_tenant_linked"] = False
subscription_data["tenant_linking_status"] = "pending"
subscription_data["linked_at"] = None
# Set default values based on plan from centralized configuration
plan = subscription_data["plan"]
plan_info = SubscriptionPlanMetadata.get_plan_info(plan)
# Set defaults from centralized plan configuration
if "monthly_price" not in subscription_data:
billing_cycle = subscription_data.get("billing_cycle", "monthly")
subscription_data["monthly_price"] = float(
PlanPricing.get_price(plan, billing_cycle)
)
if "max_users" not in subscription_data:
subscription_data["max_users"] = QuotaLimits.get_limit('MAX_USERS', plan) or -1
if "max_locations" not in subscription_data:
subscription_data["max_locations"] = QuotaLimits.get_limit('MAX_LOCATIONS', plan) or -1
if "max_products" not in subscription_data:
subscription_data["max_products"] = QuotaLimits.get_limit('MAX_PRODUCTS', plan) or -1
if "features" not in subscription_data:
subscription_data["features"] = {
feature: True for feature in plan_info.get("features", [])
}
# Set default subscription values
if "status" not in subscription_data:
subscription_data["status"] = "pending_tenant_linking"
if "billing_cycle" not in subscription_data:
subscription_data["billing_cycle"] = "monthly"
if "next_billing_date" not in subscription_data:
# Set next billing date based on cycle
if subscription_data["billing_cycle"] == "yearly":
subscription_data["next_billing_date"] = datetime.utcnow() + timedelta(days=365)
else:
subscription_data["next_billing_date"] = datetime.utcnow() + timedelta(days=30)
# Create tenant-independent subscription
subscription = await self.create(subscription_data)
logger.info("Tenant-independent subscription created successfully",
subscription_id=subscription.id,
user_id=subscription.user_id,
plan=subscription.plan,
monthly_price=subscription.monthly_price)
return subscription
except (ValidationError, DuplicateRecordError):
raise
except Exception as e:
logger.error("Failed to create tenant-independent subscription",
user_id=subscription_data.get("user_id"),
plan=subscription_data.get("plan"),
error=str(e))
raise DatabaseError(f"Failed to create tenant-independent subscription: {str(e)}")
async def get_pending_tenant_linking_subscriptions(self) -> List[Subscription]:
"""Get all subscriptions waiting to be linked to tenants"""
try:
subscriptions = await self.get_multi(
filters={
"tenant_linking_status": "pending",
"is_tenant_linked": False
},
order_by="created_at",
order_desc=True
)
return subscriptions
except Exception as e:
logger.error("Failed to get pending tenant linking subscriptions",
error=str(e))
raise DatabaseError(f"Failed to get pending subscriptions: {str(e)}")
async def get_pending_subscriptions_by_user(self, user_id: str) -> List[Subscription]:
"""Get pending tenant linking subscriptions for a specific user"""
try:
subscriptions = await self.get_multi(
filters={
"user_id": user_id,
"tenant_linking_status": "pending",
"is_tenant_linked": False
},
order_by="created_at",
order_desc=True
)
return subscriptions
except Exception as e:
logger.error("Failed to get pending subscriptions by user",
user_id=user_id,
error=str(e))
raise DatabaseError(f"Failed to get pending subscriptions: {str(e)}")
async def link_subscription_to_tenant(
self,
subscription_id: str,
tenant_id: str,
user_id: str
) -> Subscription:
"""Link a pending subscription to a tenant"""
try:
# Get the subscription first
subscription = await self.get_by_id(subscription_id)
if not subscription:
raise ValidationError(f"Subscription {subscription_id} not found")
# Validate subscription can be linked
if not subscription.can_be_linked_to_tenant(user_id):
raise ValidationError(
f"Subscription {subscription_id} cannot be linked to tenant by user {user_id}. "
f"Current status: {subscription.tenant_linking_status}, "
f"User: {subscription.user_id}, "
f"Already linked: {subscription.is_tenant_linked}"
)
# Update subscription with tenant information
update_data = {
"tenant_id": tenant_id,
"is_tenant_linked": True,
"tenant_linking_status": "completed",
"linked_at": datetime.utcnow(),
"status": "active", # Activate subscription when linked to tenant
"updated_at": datetime.utcnow()
}
updated_subscription = await self.update(subscription_id, update_data)
# Invalidate cache for the tenant
await self._invalidate_cache(tenant_id)
logger.info("Subscription linked to tenant successfully",
subscription_id=subscription_id,
tenant_id=tenant_id,
user_id=user_id)
return updated_subscription
except Exception as e:
logger.error("Failed to link subscription to tenant",
subscription_id=subscription_id,
tenant_id=tenant_id,
user_id=user_id,
error=str(e))
raise DatabaseError(f"Failed to link subscription to tenant: {str(e)}")
async def cleanup_orphaned_subscriptions(self, days_old: int = 30) -> int:
"""Clean up subscriptions that were never linked to tenants"""
try:
cutoff_date = datetime.utcnow() - timedelta(days=days_old)
query_text = """
DELETE FROM subscriptions
WHERE tenant_linking_status = 'pending'
AND is_tenant_linked = FALSE
AND created_at < :cutoff_date
"""
result = await self.session.execute(text(query_text), {"cutoff_date": cutoff_date})
deleted_count = result.rowcount
logger.info("Cleaned up orphaned subscriptions",
deleted_count=deleted_count,
days_old=days_old)
return deleted_count
except Exception as e:
logger.error("Failed to cleanup orphaned subscriptions",
error=str(e))
raise DatabaseError(f"Cleanup failed: {str(e)}")

View File

@@ -19,6 +19,9 @@ class BakeryRegistration(BaseModel):
business_type: str = Field(default="bakery")
business_model: Optional[str] = Field(default="individual_bakery")
coupon_code: Optional[str] = Field(None, max_length=50, description="Promotional coupon code")
# Subscription linking fields (for new multi-phase registration architecture)
subscription_id: Optional[str] = Field(None, description="Existing subscription ID to link to this tenant")
link_existing_subscription: Optional[bool] = Field(False, description="Flag to link an existing subscription during tenant creation")
@field_validator('phone')
@classmethod
@@ -350,6 +353,29 @@ class BulkChildTenantsResponse(BaseModel):
return str(v)
return v
class TenantHierarchyResponse(BaseModel):
"""Response schema for tenant hierarchy information"""
tenant_id: str
tenant_type: str = Field(..., description="Type: standalone, parent, or child")
parent_tenant_id: Optional[str] = Field(None, description="Parent tenant ID if this is a child")
hierarchy_path: Optional[str] = Field(None, description="Materialized path for hierarchy queries")
child_count: int = Field(0, description="Number of child tenants (for parent tenants)")
hierarchy_level: int = Field(0, description="Level in hierarchy: 0=parent, 1=child, 2=grandchild, etc.")
@field_validator('tenant_id', 'parent_tenant_id', mode='before')
@classmethod
def convert_uuid_to_string(cls, v):
"""Convert UUID objects to strings for JSON serialization"""
if v is None:
return v
if isinstance(v, UUID):
return str(v)
return v
class Config:
from_attributes = True
class TenantSearchRequest(BaseModel):
"""Tenant search request schema"""
query: Optional[str] = None

View File

@@ -4,8 +4,16 @@ Business logic services for tenant operations
"""
from .tenant_service import TenantService, EnhancedTenantService
from .subscription_service import SubscriptionService
from .payment_service import PaymentService
from .coupon_service import CouponService
from .subscription_orchestration_service import SubscriptionOrchestrationService
__all__ = [
"TenantService",
"EnhancedTenantService"
"EnhancedTenantService",
"SubscriptionService",
"PaymentService",
"CouponService",
"SubscriptionOrchestrationService"
]

View File

@@ -0,0 +1,108 @@
"""
Coupon Service - Coupon Operations
This service handles ONLY coupon validation and redemption
NO payment provider interactions, NO subscription logic
"""
import structlog
from typing import Dict, Any, Optional, Tuple
from sqlalchemy.ext.asyncio import AsyncSession
from app.repositories.coupon_repository import CouponRepository
logger = structlog.get_logger()
class CouponService:
"""Service for handling coupon validation and redemption"""
def __init__(self, db_session: AsyncSession):
self.db_session = db_session
self.coupon_repo = CouponRepository(db_session)
async def validate_coupon_code(
self,
coupon_code: str,
tenant_id: str
) -> Dict[str, Any]:
"""
Validate a coupon code for a tenant
Args:
coupon_code: Coupon code to validate
tenant_id: Tenant ID
Returns:
Dictionary with validation results
"""
try:
validation = await self.coupon_repo.validate_coupon(coupon_code, tenant_id)
return {
"valid": validation.valid,
"error_message": validation.error_message,
"discount_preview": validation.discount_preview,
"coupon": {
"code": validation.coupon.code,
"discount_type": validation.coupon.discount_type.value,
"discount_value": validation.coupon.discount_value
} if validation.coupon else None
}
except Exception as e:
logger.error("Failed to validate coupon", error=str(e), coupon_code=coupon_code)
return {
"valid": False,
"error_message": "Error al validar el cupón",
"discount_preview": None,
"coupon": None
}
async def redeem_coupon(
self,
coupon_code: str,
tenant_id: str,
base_trial_days: int = 14
) -> Tuple[bool, Optional[Dict[str, Any]], Optional[str]]:
"""
Redeem a coupon for a tenant
Args:
coupon_code: Coupon code to redeem
tenant_id: Tenant ID
base_trial_days: Base trial days without coupon
Returns:
Tuple of (success, discount_applied, error_message)
"""
try:
success, redemption, error = await self.coupon_repo.redeem_coupon(
coupon_code,
tenant_id,
base_trial_days
)
if success and redemption:
return True, redemption.discount_applied, None
else:
return False, None, error
except Exception as e:
logger.error("Failed to redeem coupon", error=str(e), coupon_code=coupon_code)
return False, None, f"Error al aplicar el cupón: {str(e)}"
async def get_coupon_by_code(self, coupon_code: str) -> Optional[Any]:
"""
Get coupon details by code
Args:
coupon_code: Coupon code to retrieve
Returns:
Coupon object or None
"""
try:
return await self.coupon_repo.get_coupon_by_code(coupon_code)
except Exception as e:
logger.error("Failed to get coupon by code", error=str(e), coupon_code=coupon_code)
return None

View File

@@ -1,41 +1,30 @@
"""
Payment Service for handling subscription payments
This service abstracts payment provider interactions and makes the system payment-agnostic
Payment Service - Payment Provider Gateway
This service handles ONLY payment provider interactions (Stripe, etc.)
NO business logic, NO database operations, NO orchestration
"""
import structlog
from typing import Dict, Any, Optional
import uuid
from typing import Dict, Any, Optional, List
from datetime import datetime
from sqlalchemy.orm import Session
from app.core.config import settings
from shared.clients.payment_client import PaymentProvider, PaymentCustomer, Subscription, PaymentMethod
from shared.clients.stripe_client import StripeProvider
from shared.database.base import create_database_manager
from app.repositories.subscription_repository import SubscriptionRepository
from app.repositories.coupon_repository import CouponRepository
from app.models.tenants import Subscription as SubscriptionModel
logger = structlog.get_logger()
class PaymentService:
"""Service for handling payment provider interactions"""
"""Service for handling payment provider interactions ONLY"""
def __init__(self, db_session: Optional[Session] = None):
def __init__(self):
# Initialize payment provider based on configuration
# For now, we'll use Stripe, but this can be swapped for other providers
self.payment_provider: PaymentProvider = StripeProvider(
api_key=settings.STRIPE_SECRET_KEY,
webhook_secret=settings.STRIPE_WEBHOOK_SECRET
)
# Initialize database components
self.database_manager = create_database_manager(settings.DATABASE_URL, "tenant-service")
self.subscription_repo = SubscriptionRepository(SubscriptionModel, None) # Will be set in methods
self.db_session = db_session # Optional session for coupon operations
async def create_customer(self, user_data: Dict[str, Any]) -> PaymentCustomer:
"""Create a customer in the payment provider system"""
try:
@@ -47,257 +36,408 @@ class PaymentService:
'tenant_id': user_data.get('tenant_id')
}
}
return await self.payment_provider.create_customer(customer_data)
except Exception as e:
logger.error("Failed to create customer in payment provider", error=str(e))
raise e
async def create_subscription(
self,
customer_id: str,
plan_id: str,
payment_method_id: str,
trial_period_days: Optional[int] = None
async def create_payment_subscription(
self,
customer_id: str,
plan_id: str,
payment_method_id: str,
trial_period_days: Optional[int] = None,
billing_interval: str = "monthly"
) -> Subscription:
"""Create a subscription for a customer"""
"""
Create a subscription in the payment provider
Args:
customer_id: Payment provider customer ID
plan_id: Plan identifier
payment_method_id: Payment method ID
trial_period_days: Optional trial period in days
billing_interval: Billing interval (monthly/yearly)
Returns:
Subscription object from payment provider
"""
try:
# Map the plan ID to the actual Stripe price ID
stripe_price_id = self._get_stripe_price_id(plan_id, billing_interval)
return await self.payment_provider.create_subscription(
customer_id,
plan_id,
payment_method_id,
customer_id,
stripe_price_id,
payment_method_id,
trial_period_days
)
except Exception as e:
logger.error("Failed to create subscription in payment provider", error=str(e))
logger.error("Failed to create subscription in payment provider",
error=str(e),
error_type=type(e).__name__,
customer_id=customer_id,
plan_id=plan_id,
billing_interval=billing_interval,
exc_info=True)
raise e
def validate_coupon_code(
self,
coupon_code: str,
tenant_id: str,
db_session: Session
) -> Dict[str, Any]:
"""
Validate a coupon code for a tenant.
Returns validation result with discount preview.
"""
try:
coupon_repo = CouponRepository(db_session)
validation = coupon_repo.validate_coupon(coupon_code, tenant_id)
return {
"valid": validation.valid,
"error_message": validation.error_message,
"discount_preview": validation.discount_preview,
"coupon": {
"code": validation.coupon.code,
"discount_type": validation.coupon.discount_type.value,
"discount_value": validation.coupon.discount_value
} if validation.coupon else None
}
except Exception as e:
logger.error("Failed to validate coupon", error=str(e), coupon_code=coupon_code)
return {
"valid": False,
"error_message": "Error al validar el cupón",
"discount_preview": None,
"coupon": None
}
def redeem_coupon(
self,
coupon_code: str,
tenant_id: str,
db_session: Session,
base_trial_days: int = 14
) -> tuple[bool, Optional[Dict[str, Any]], Optional[str]]:
def _get_stripe_price_id(self, plan_id: str, billing_interval: str) -> str:
"""
Redeem a coupon for a tenant.
Returns (success, discount_applied, error_message)
Get Stripe price ID for a given plan and billing interval
Args:
plan_id: Subscription plan (starter, professional, enterprise)
billing_interval: Billing interval (monthly, yearly)
Returns:
Stripe price ID
Raises:
ValueError: If plan or billing interval is invalid
"""
try:
coupon_repo = CouponRepository(db_session)
success, redemption, error = coupon_repo.redeem_coupon(
coupon_code,
tenant_id,
base_trial_days
plan_id = plan_id.lower()
billing_interval = billing_interval.lower()
price_id = settings.STRIPE_PRICE_ID_MAPPING.get((plan_id, billing_interval))
if not price_id:
valid_combinations = list(settings.STRIPE_PRICE_ID_MAPPING.keys())
raise ValueError(
f"Invalid plan or billing interval: {plan_id}/{billing_interval}. "
f"Valid combinations: {valid_combinations}"
)
if success and redemption:
return True, redemption.discount_applied, None
else:
return False, None, error
return price_id
async def cancel_payment_subscription(self, subscription_id: str) -> Subscription:
"""
Cancel a subscription in the payment provider
Args:
subscription_id: Payment provider subscription ID
Returns:
Updated Subscription object
"""
try:
return await self.payment_provider.cancel_subscription(subscription_id)
except Exception as e:
logger.error("Failed to cancel subscription in payment provider", error=str(e))
raise e
async def update_payment_method(self, customer_id: str, payment_method_id: str) -> PaymentMethod:
"""
Update the payment method for a customer
Args:
customer_id: Payment provider customer ID
payment_method_id: New payment method ID
Returns:
PaymentMethod object
"""
try:
return await self.payment_provider.update_payment_method(customer_id, payment_method_id)
except Exception as e:
logger.error("Failed to update payment method in payment provider", error=str(e))
raise e
async def get_payment_subscription(self, subscription_id: str) -> Subscription:
"""
Get subscription details from the payment provider
Args:
subscription_id: Payment provider subscription ID
Returns:
Subscription object
"""
try:
return await self.payment_provider.get_subscription(subscription_id)
except Exception as e:
logger.error("Failed to get subscription from payment provider", error=str(e))
raise e
async def update_payment_subscription(
self,
subscription_id: str,
new_price_id: str,
proration_behavior: str = "create_prorations",
billing_cycle_anchor: str = "unchanged",
payment_behavior: str = "error_if_incomplete",
immediate_change: bool = False
) -> Subscription:
"""
Update a subscription in the payment provider
Args:
subscription_id: Payment provider subscription ID
new_price_id: New price ID to switch to
proration_behavior: How to handle prorations
billing_cycle_anchor: When to apply changes
payment_behavior: Payment behavior
immediate_change: Whether to apply changes immediately
Returns:
Updated Subscription object
"""
try:
return await self.payment_provider.update_subscription(
subscription_id,
new_price_id,
proration_behavior,
billing_cycle_anchor,
payment_behavior,
immediate_change
)
except Exception as e:
logger.error("Failed to update subscription in payment provider", error=str(e))
raise e
async def calculate_payment_proration(
self,
subscription_id: str,
new_price_id: str,
proration_behavior: str = "create_prorations"
) -> Dict[str, Any]:
"""
Calculate proration amounts for a subscription change
Args:
subscription_id: Payment provider subscription ID
new_price_id: New price ID
proration_behavior: Proration behavior to use
Returns:
Dictionary with proration details
"""
try:
return await self.payment_provider.calculate_proration(
subscription_id,
new_price_id,
proration_behavior
)
except Exception as e:
logger.error("Failed to calculate proration", error=str(e))
raise e
async def change_billing_cycle(
self,
subscription_id: str,
new_billing_cycle: str,
proration_behavior: str = "create_prorations"
) -> Subscription:
"""
Change billing cycle (monthly ↔ yearly) for a subscription
Args:
subscription_id: Payment provider subscription ID
new_billing_cycle: New billing cycle ('monthly' or 'yearly')
proration_behavior: Proration behavior to use
Returns:
Updated Subscription object
"""
try:
return await self.payment_provider.change_billing_cycle(
subscription_id,
new_billing_cycle,
proration_behavior
)
except Exception as e:
logger.error("Failed to change billing cycle", error=str(e))
raise e
async def get_invoices_from_provider(
self,
customer_id: str
) -> List[Dict[str, Any]]:
"""
Get invoice history for a customer from payment provider
Args:
customer_id: Payment provider customer ID
Returns:
List of invoice dictionaries
"""
try:
# Fetch invoices from payment provider
stripe_invoices = await self.payment_provider.get_invoices(customer_id)
# Transform to response format
invoices = []
for invoice in stripe_invoices:
invoices.append({
"id": invoice.id,
"date": invoice.created_at.strftime('%Y-%m-%d'),
"amount": invoice.amount,
"currency": invoice.currency.upper(),
"status": invoice.status,
"description": invoice.description,
"invoice_pdf": invoice.invoice_pdf,
"hosted_invoice_url": invoice.hosted_invoice_url
})
logger.info("invoices_retrieved_from_provider",
customer_id=customer_id,
count=len(invoices))
return invoices
except Exception as e:
logger.error("Failed to redeem coupon", error=str(e), coupon_code=coupon_code)
return False, None, f"Error al aplicar el cupón: {str(e)}"
logger.error("Failed to get invoices from payment provider",
error=str(e),
customer_id=customer_id)
raise e
async def verify_webhook_signature(
self,
payload: bytes,
signature: str
) -> Dict[str, Any]:
"""
Verify webhook signature from payment provider
Args:
payload: Raw webhook payload
signature: Webhook signature header
Returns:
Verified event data
Raises:
Exception: If signature verification fails
"""
try:
import stripe
event = stripe.Webhook.construct_event(
payload, signature, settings.STRIPE_WEBHOOK_SECRET
)
logger.info("Webhook signature verified", event_type=event['type'])
return event
except stripe.error.SignatureVerificationError as e:
logger.error("Invalid webhook signature", error=str(e))
raise e
except Exception as e:
logger.error("Failed to verify webhook signature", error=str(e))
raise e
async def process_registration_with_subscription(
self,
user_data: Dict[str, Any],
plan_id: str,
payment_method_id: str,
use_trial: bool = False,
coupon_code: Optional[str] = None,
db_session: Optional[Session] = None
billing_interval: str = "monthly"
) -> Dict[str, Any]:
"""Process user registration with subscription creation"""
"""
Process user registration with subscription creation
This method handles the complete flow:
1. Create payment customer (if not exists)
2. Attach payment method to customer
3. Create subscription with coupon/trial
4. Return subscription details
Args:
user_data: User data including email, name, etc.
plan_id: Subscription plan ID
payment_method_id: Payment method ID from frontend
coupon_code: Optional coupon code for discounts/trials
billing_interval: Billing interval (monthly/yearly)
Returns:
Dictionary with subscription and customer details
"""
try:
# Create customer in payment provider
# Step 1: Create or get payment customer
customer = await self.create_customer(user_data)
# Determine trial period (default 14 days)
trial_period_days = 14 if use_trial else 0
# Apply coupon if provided
coupon_discount = None
if coupon_code and db_session:
# Redeem coupon
success, discount, error = self.redeem_coupon(
coupon_code,
user_data.get('tenant_id'),
db_session,
trial_period_days
)
if success and discount:
coupon_discount = discount
# Update trial period if coupon extends it
if discount.get("type") == "trial_extension":
trial_period_days = discount.get("total_trial_days", trial_period_days)
logger.info(
"Coupon applied successfully",
coupon_code=coupon_code,
extended_trial_days=trial_period_days
)
logger.info("Payment customer created for registration",
customer_id=customer.id,
email=user_data.get('email'))
# Step 2: Attach payment method to customer
if payment_method_id:
try:
payment_method = await self.update_payment_method(customer.id, payment_method_id)
logger.info("Payment method attached to customer",
customer_id=customer.id,
payment_method_id=payment_method.id)
except Exception as e:
logger.warning("Failed to attach payment method, but continuing with subscription",
customer_id=customer.id,
error=str(e))
# Continue without attached payment method - user can add it later
payment_method = None
# Step 3: Determine trial period from coupon
trial_period_days = None
if coupon_code:
# Check if coupon provides a trial period
# In a real implementation, you would validate the coupon here
# For now, we'll assume PILOT2025 provides a trial
if coupon_code.upper() == "PILOT2025":
trial_period_days = 90 # 3 months trial for pilot users
logger.info("Pilot coupon detected - applying 90-day trial",
coupon_code=coupon_code,
customer_id=customer.id)
else:
logger.warning("Failed to apply coupon", error=error, coupon_code=coupon_code)
# Create subscription
subscription = await self.create_subscription(
# Other coupons might provide different trial periods
# This would be configured in your coupon system
trial_period_days = 30 # Default trial for other coupons
# Step 4: Create subscription
subscription = await self.create_payment_subscription(
customer.id,
plan_id,
payment_method_id,
trial_period_days if trial_period_days > 0 else None
payment_method_id if payment_method_id else None,
trial_period_days,
billing_interval
)
# Save subscription to database
async with self.database_manager.get_session() as session:
self.subscription_repo.session = session
subscription_data = {
'id': str(uuid.uuid4()),
'tenant_id': user_data.get('tenant_id'),
'customer_id': customer.id,
'subscription_id': subscription.id,
'plan_id': plan_id,
'status': subscription.status,
'current_period_start': subscription.current_period_start,
'current_period_end': subscription.current_period_end,
'created_at': subscription.created_at,
'trial_period_days': trial_period_days if trial_period_days > 0 else None
}
subscription_record = await self.subscription_repo.create(subscription_data)
result = {
'customer_id': customer.id,
'subscription_id': subscription.id,
'status': subscription.status,
'trial_period_days': trial_period_days
logger.info("Subscription created successfully during registration",
subscription_id=subscription.id,
customer_id=customer.id,
plan_id=plan_id,
status=subscription.status)
# Step 5: Return comprehensive result
return {
"success": True,
"customer": {
"id": customer.id,
"email": customer.email,
"name": customer.name,
"created_at": customer.created_at.isoformat()
},
"subscription": {
"id": subscription.id,
"customer_id": subscription.customer_id,
"plan_id": plan_id,
"status": subscription.status,
"current_period_start": subscription.current_period_start.isoformat(),
"current_period_end": subscription.current_period_end.isoformat(),
"trial_period_days": trial_period_days,
"billing_interval": billing_interval
},
"payment_method": {
"id": payment_method.id if payment_method else None,
"type": payment_method.type if payment_method else None,
"last4": payment_method.last4 if payment_method else None
} if payment_method else None,
"coupon_applied": coupon_code is not None,
"trial_active": trial_period_days is not None and trial_period_days > 0
}
# Include coupon info if applied
if coupon_discount:
result['coupon_applied'] = {
'code': coupon_code,
'discount': coupon_discount
}
return result
except Exception as e:
logger.error("Failed to process registration with subscription", error=str(e))
raise e
async def cancel_subscription(self, subscription_id: str) -> Subscription:
"""Cancel a subscription in the payment provider"""
try:
return await self.payment_provider.cancel_subscription(subscription_id)
except Exception as e:
logger.error("Failed to cancel subscription in payment provider", error=str(e))
raise e
async def update_payment_method(self, customer_id: str, payment_method_id: str) -> PaymentMethod:
"""Update the payment method for a customer"""
try:
return await self.payment_provider.update_payment_method(customer_id, payment_method_id)
except Exception as e:
logger.error("Failed to update payment method in payment provider", error=str(e))
raise e
async def get_invoices(self, customer_id: str) -> list:
"""Get invoices for a customer from the payment provider"""
try:
return await self.payment_provider.get_invoices(customer_id)
except Exception as e:
logger.error("Failed to get invoices from payment provider", error=str(e))
raise e
async def get_subscription(self, subscription_id: str) -> Subscription:
"""Get subscription details from the payment provider"""
try:
return await self.payment_provider.get_subscription(subscription_id)
except Exception as e:
logger.error("Failed to get subscription from payment provider", error=str(e))
raise e
async def sync_subscription_status(self, subscription_id: str, db_session: Session) -> Subscription:
"""
Sync subscription status from payment provider to database
This ensures our local subscription status matches the payment provider
"""
try:
# Get current subscription from payment provider
stripe_subscription = await self.payment_provider.get_subscription(subscription_id)
logger.info("Syncing subscription status",
subscription_id=subscription_id,
stripe_status=stripe_subscription.status)
# Update local database record
self.subscription_repo.db_session = db_session
local_subscription = await self.subscription_repo.get_by_stripe_id(subscription_id)
if local_subscription:
# Update status and dates
local_subscription.status = stripe_subscription.status
local_subscription.current_period_end = stripe_subscription.current_period_end
# Handle status-specific logic
if stripe_subscription.status == 'active':
local_subscription.is_active = True
local_subscription.canceled_at = None
elif stripe_subscription.status == 'canceled':
local_subscription.is_active = False
local_subscription.canceled_at = datetime.utcnow()
elif stripe_subscription.status == 'past_due':
local_subscription.is_active = False
elif stripe_subscription.status == 'unpaid':
local_subscription.is_active = False
await self.subscription_repo.update(local_subscription)
logger.info("Subscription status synced successfully",
subscription_id=subscription_id,
new_status=stripe_subscription.status)
else:
logger.warning("Local subscription not found for Stripe subscription",
subscription_id=subscription_id)
return stripe_subscription
except Exception as e:
logger.error("Failed to sync subscription status",
error=str(e),
subscription_id=subscription_id)
logger.error("Failed to process registration with subscription",
error=str(e),
plan_id=plan_id,
customer_email=user_data.get('email'))
raise e

View File

@@ -520,7 +520,7 @@ class SubscriptionLimitService:
from shared.clients.inventory_client import create_inventory_client
# Use the shared inventory client with proper authentication
inventory_client = create_inventory_client(settings)
inventory_client = create_inventory_client(settings, service_name="tenant")
count = await inventory_client.count_ingredients(tenant_id)
logger.info(
@@ -545,7 +545,7 @@ class SubscriptionLimitService:
from app.core.config import settings
# Use the shared recipes client with proper authentication and resilience
recipes_client = create_recipes_client(settings)
recipes_client = create_recipes_client(settings, service_name="tenant")
count = await recipes_client.count_recipes(tenant_id)
logger.info(
@@ -570,7 +570,7 @@ class SubscriptionLimitService:
from app.core.config import settings
# Use the shared suppliers client with proper authentication and resilience
suppliers_client = create_suppliers_client(settings)
suppliers_client = create_suppliers_client(settings, service_name="tenant")
count = await suppliers_client.count_suppliers(tenant_id)
logger.info(

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,7 @@
"""
Subscription Service for managing subscription lifecycle operations
This service orchestrates business logic and integrates with payment providers
Subscription Service - State Manager
This service handles ONLY subscription database operations and state management
NO payment provider interactions, NO orchestration, NO coupon logic
"""
import structlog
@@ -12,92 +13,247 @@ from sqlalchemy import select
from app.models.tenants import Subscription, Tenant
from app.repositories.subscription_repository import SubscriptionRepository
from app.services.payment_service import PaymentService
from shared.clients.stripe_client import StripeProvider
from app.core.config import settings
from shared.database.exceptions import DatabaseError, ValidationError
from shared.subscription.plans import PlanPricing, QuotaLimits, SubscriptionPlanMetadata
logger = structlog.get_logger()
class SubscriptionService:
"""Service for managing subscription lifecycle operations"""
"""Service for managing subscription state and database operations ONLY"""
def __init__(self, db_session: AsyncSession):
self.db_session = db_session
self.subscription_repo = SubscriptionRepository(Subscription, db_session)
self.payment_service = PaymentService()
async def create_subscription_record(
self,
tenant_id: str,
stripe_subscription_id: str,
stripe_customer_id: str,
plan: str,
status: str,
trial_period_days: Optional[int] = None,
billing_interval: str = "monthly"
) -> Subscription:
"""
Create a local subscription record in the database
Args:
tenant_id: Tenant ID
stripe_subscription_id: Stripe subscription ID
stripe_customer_id: Stripe customer ID
plan: Subscription plan
status: Subscription status
trial_period_days: Optional trial period in days
billing_interval: Billing interval (monthly or yearly)
Returns:
Created Subscription object
"""
try:
tenant_uuid = UUID(tenant_id)
# Verify tenant exists
query = select(Tenant).where(Tenant.id == tenant_uuid)
result = await self.db_session.execute(query)
tenant = result.scalar_one_or_none()
if not tenant:
raise ValidationError(f"Tenant not found: {tenant_id}")
# Create local subscription record
subscription_data = {
'tenant_id': str(tenant_id),
'subscription_id': stripe_subscription_id, # Stripe subscription ID
'customer_id': stripe_customer_id, # Stripe customer ID
'plan_id': plan,
'status': status,
'created_at': datetime.now(timezone.utc),
'trial_period_days': trial_period_days,
'billing_cycle': billing_interval
}
created_subscription = await self.subscription_repo.create(subscription_data)
logger.info("subscription_record_created",
tenant_id=tenant_id,
subscription_id=stripe_subscription_id,
plan=plan)
return created_subscription
except ValidationError as ve:
logger.error("create_subscription_record_validation_failed",
error=str(ve), tenant_id=tenant_id)
raise ve
except Exception as e:
logger.error("create_subscription_record_failed", error=str(e), tenant_id=tenant_id)
raise DatabaseError(f"Failed to create subscription record: {str(e)}")
async def update_subscription_status(
self,
tenant_id: str,
status: str,
stripe_data: Optional[Dict[str, Any]] = None
) -> Subscription:
"""
Update subscription status in database
Args:
tenant_id: Tenant ID
status: New subscription status
stripe_data: Optional data from Stripe to update
Returns:
Updated Subscription object
"""
try:
tenant_uuid = UUID(tenant_id)
# Get subscription from repository
subscription = await self.subscription_repo.get_by_tenant_id(str(tenant_uuid))
if not subscription:
raise ValidationError(f"Subscription not found for tenant {tenant_id}")
# Prepare update data
update_data = {
'status': status,
'updated_at': datetime.now(timezone.utc)
}
# Include Stripe data if provided
if stripe_data:
if 'current_period_start' in stripe_data:
update_data['current_period_start'] = stripe_data['current_period_start']
if 'current_period_end' in stripe_data:
update_data['current_period_end'] = stripe_data['current_period_end']
# Update status flags based on status value
if status == 'active':
update_data['is_active'] = True
update_data['canceled_at'] = None
elif status in ['canceled', 'past_due', 'unpaid', 'inactive']:
update_data['is_active'] = False
elif status == 'pending_cancellation':
update_data['is_active'] = True # Still active until effective date
updated_subscription = await self.subscription_repo.update(str(subscription.id), update_data)
# Invalidate subscription cache
await self._invalidate_cache(tenant_id)
logger.info("subscription_status_updated",
tenant_id=tenant_id,
old_status=subscription.status,
new_status=status)
return updated_subscription
except ValidationError as ve:
logger.error("update_subscription_status_validation_failed",
error=str(ve), tenant_id=tenant_id)
raise ve
except Exception as e:
logger.error("update_subscription_status_failed", error=str(e), tenant_id=tenant_id)
raise DatabaseError(f"Failed to update subscription status: {str(e)}")
async def get_subscription_by_tenant_id(
self,
tenant_id: str
) -> Optional[Subscription]:
"""
Get subscription by tenant ID
Args:
tenant_id: Tenant ID
Returns:
Subscription object or None
"""
try:
tenant_uuid = UUID(tenant_id)
return await self.subscription_repo.get_by_tenant_id(str(tenant_uuid))
except Exception as e:
logger.error("get_subscription_by_tenant_id_failed",
error=str(e), tenant_id=tenant_id)
return None
async def get_subscription_by_stripe_id(
self,
stripe_subscription_id: str
) -> Optional[Subscription]:
"""
Get subscription by Stripe subscription ID
Args:
stripe_subscription_id: Stripe subscription ID
Returns:
Subscription object or None
"""
try:
return await self.subscription_repo.get_by_stripe_id(stripe_subscription_id)
except Exception as e:
logger.error("get_subscription_by_stripe_id_failed",
error=str(e), stripe_subscription_id=stripe_subscription_id)
return None
async def cancel_subscription(
self,
tenant_id: str,
reason: str = ""
) -> Dict[str, Any]:
"""
Cancel a subscription with proper business logic and payment provider integration
Mark subscription as pending cancellation in database
Args:
tenant_id: Tenant ID to cancel subscription for
reason: Optional cancellation reason
Returns:
Dictionary with cancellation details
"""
try:
tenant_uuid = UUID(tenant_id)
# Get subscription from repository
subscription = await self.subscription_repo.get_by_tenant_id(str(tenant_uuid))
if not subscription:
raise ValidationError(f"Subscription not found for tenant {tenant_id}")
if subscription.status in ['pending_cancellation', 'inactive']:
raise ValidationError(f"Subscription is already {subscription.status}")
# Calculate cancellation effective date (end of billing period)
cancellation_effective_date = subscription.next_billing_date or (
datetime.now(timezone.utc) + timedelta(days=30)
)
# Update subscription status in database
update_data = {
'status': 'pending_cancellation',
'cancelled_at': datetime.now(timezone.utc),
'cancellation_effective_date': cancellation_effective_date
}
updated_subscription = await self.subscription_repo.update(str(subscription.id), update_data)
# Invalidate subscription cache
try:
from app.services.subscription_cache import get_subscription_cache_service
import shared.redis_utils
await self._invalidate_cache(tenant_id)
redis_client = await shared.redis_utils.initialize_redis(settings.REDIS_URL)
cache_service = get_subscription_cache_service(redis_client)
await cache_service.invalidate_subscription_cache(str(tenant_id))
logger.info(
"Subscription cache invalidated after cancellation",
tenant_id=str(tenant_id)
)
except Exception as cache_error:
logger.error(
"Failed to invalidate subscription cache after cancellation",
tenant_id=str(tenant_id),
error=str(cache_error)
)
days_remaining = (cancellation_effective_date - datetime.now(timezone.utc)).days
logger.info(
"subscription_cancelled",
tenant_id=str(tenant_id),
effective_date=cancellation_effective_date.isoformat(),
reason=reason[:200] if reason else None
)
return {
"success": True,
"message": "Subscription cancelled successfully. You will have read-only access until the end of your billing period.",
@@ -106,9 +262,9 @@ class SubscriptionService:
"days_remaining": days_remaining,
"read_only_mode_starts": cancellation_effective_date.isoformat()
}
except ValidationError as ve:
logger.error("subscription_cancellation_validation_failed",
logger.error("subscription_cancellation_validation_failed",
error=str(ve), tenant_id=tenant_id)
raise ve
except Exception as e:
@@ -122,65 +278,48 @@ class SubscriptionService:
) -> Dict[str, Any]:
"""
Reactivate a cancelled or inactive subscription
Args:
tenant_id: Tenant ID to reactivate subscription for
plan: Plan to reactivate with
Returns:
Dictionary with reactivation details
"""
try:
tenant_uuid = UUID(tenant_id)
# Get subscription from repository
subscription = await self.subscription_repo.get_by_tenant_id(str(tenant_uuid))
if not subscription:
raise ValidationError(f"Subscription not found for tenant {tenant_id}")
if subscription.status not in ['pending_cancellation', 'inactive']:
raise ValidationError(f"Cannot reactivate subscription with status: {subscription.status}")
# Update subscription status and plan
update_data = {
'status': 'active',
'plan': plan,
'plan_id': plan,
'cancelled_at': None,
'cancellation_effective_date': None
}
if subscription.status == 'inactive':
update_data['next_billing_date'] = datetime.now(timezone.utc) + timedelta(days=30)
updated_subscription = await self.subscription_repo.update(str(subscription.id), update_data)
# Invalidate subscription cache
try:
from app.services.subscription_cache import get_subscription_cache_service
import shared.redis_utils
await self._invalidate_cache(tenant_id)
redis_client = await shared.redis_utils.initialize_redis(settings.REDIS_URL)
cache_service = get_subscription_cache_service(redis_client)
await cache_service.invalidate_subscription_cache(str(tenant_id))
logger.info(
"Subscription cache invalidated after reactivation",
tenant_id=str(tenant_id)
)
except Exception as cache_error:
logger.error(
"Failed to invalidate subscription cache after reactivation",
tenant_id=str(tenant_id),
error=str(cache_error)
)
logger.info(
"subscription_reactivated",
tenant_id=str(tenant_id),
new_plan=plan
)
return {
"success": True,
"message": "Subscription reactivated successfully",
@@ -188,9 +327,9 @@ class SubscriptionService:
"plan": plan,
"next_billing_date": updated_subscription.next_billing_date.isoformat() if updated_subscription.next_billing_date else None
}
except ValidationError as ve:
logger.error("subscription_reactivation_validation_failed",
logger.error("subscription_reactivation_validation_failed",
error=str(ve), tenant_id=tenant_id)
raise ve
except Exception as e:
@@ -203,28 +342,28 @@ class SubscriptionService:
) -> Dict[str, Any]:
"""
Get current subscription status including read-only mode info
Args:
tenant_id: Tenant ID to get status for
Returns:
Dictionary with subscription status details
"""
try:
tenant_uuid = UUID(tenant_id)
# Get subscription from repository
subscription = await self.subscription_repo.get_by_tenant_id(str(tenant_uuid))
if not subscription:
raise ValidationError(f"Subscription not found for tenant {tenant_id}")
is_read_only = subscription.status in ['pending_cancellation', 'inactive']
days_until_inactive = None
if subscription.status == 'pending_cancellation' and subscription.cancellation_effective_date:
days_until_inactive = (subscription.cancellation_effective_date - datetime.now(timezone.utc)).days
return {
"tenant_id": str(tenant_id),
"status": subscription.status,
@@ -233,192 +372,332 @@ class SubscriptionService:
"cancellation_effective_date": subscription.cancellation_effective_date.isoformat() if subscription.cancellation_effective_date else None,
"days_until_inactive": days_until_inactive
}
except ValidationError as ve:
logger.error("get_subscription_status_validation_failed",
logger.error("get_subscription_status_validation_failed",
error=str(ve), tenant_id=tenant_id)
raise ve
except Exception as e:
logger.error("get_subscription_status_failed", error=str(e), tenant_id=tenant_id)
raise DatabaseError(f"Failed to get subscription status: {str(e)}")
async def get_tenant_invoices(
self,
tenant_id: str
) -> List[Dict[str, Any]]:
"""
Get invoice history for a tenant from payment provider
Args:
tenant_id: Tenant ID to get invoices for
Returns:
List of invoice dictionaries
"""
try:
tenant_uuid = UUID(tenant_id)
# Verify tenant exists
query = select(Tenant).where(Tenant.id == tenant_uuid)
result = await self.db_session.execute(query)
tenant = result.scalar_one_or_none()
if not tenant:
raise ValidationError(f"Tenant not found: {tenant_id}")
# Check if tenant has a payment provider customer ID
if not tenant.stripe_customer_id:
logger.info("no_stripe_customer_id", tenant_id=tenant_id)
return []
# Initialize payment provider (Stripe in this case)
stripe_provider = StripeProvider(
api_key=settings.STRIPE_SECRET_KEY,
webhook_secret=settings.STRIPE_WEBHOOK_SECRET
)
# Fetch invoices from payment provider
stripe_invoices = await stripe_provider.get_invoices(tenant.stripe_customer_id)
# Transform to response format
invoices = []
for invoice in stripe_invoices:
invoices.append({
"id": invoice.id,
"date": invoice.created_at.strftime('%Y-%m-%d'),
"amount": invoice.amount,
"currency": invoice.currency.upper(),
"status": invoice.status,
"description": invoice.description,
"invoice_pdf": invoice.invoice_pdf,
"hosted_invoice_url": invoice.hosted_invoice_url
})
logger.info("invoices_retrieved", tenant_id=tenant_id, count=len(invoices))
return invoices
except ValidationError as ve:
logger.error("get_invoices_validation_failed",
error=str(ve), tenant_id=tenant_id)
raise ve
except Exception as e:
logger.error("get_invoices_failed", error=str(e), tenant_id=tenant_id)
raise DatabaseError(f"Failed to retrieve invoices: {str(e)}")
async def create_subscription(
async def update_subscription_plan_record(
self,
tenant_id: str,
plan: str,
payment_method_id: str,
trial_period_days: Optional[int] = None
new_plan: str,
new_status: str,
new_period_start: datetime,
new_period_end: datetime,
billing_cycle: str = "monthly",
proration_details: Optional[Dict[str, Any]] = None
) -> Dict[str, Any]:
"""
Create a new subscription for a tenant
Update local subscription plan record in database
Args:
tenant_id: Tenant ID
plan: Subscription plan
payment_method_id: Payment method ID from payment provider
trial_period_days: Optional trial period in days
new_plan: New plan name
new_status: New subscription status
new_period_start: New period start date
new_period_end: New period end date
billing_cycle: Billing cycle for the new plan
proration_details: Proration details from payment provider
Returns:
Dictionary with subscription creation details
Dictionary with update results
"""
try:
tenant_uuid = UUID(tenant_id)
# Verify tenant exists
query = select(Tenant).where(Tenant.id == tenant_uuid)
result = await self.db_session.execute(query)
tenant = result.scalar_one_or_none()
if not tenant:
raise ValidationError(f"Tenant not found: {tenant_id}")
if not tenant.stripe_customer_id:
raise ValidationError(f"Tenant {tenant_id} does not have a payment provider customer ID")
# Create subscription through payment provider
subscription_result = await self.payment_service.create_subscription(
tenant.stripe_customer_id,
plan,
payment_method_id,
trial_period_days
)
# Create local subscription record
subscription_data = {
'tenant_id': str(tenant_id),
'stripe_subscription_id': subscription_result.id,
'plan': plan,
'status': subscription_result.status,
'current_period_start': subscription_result.current_period_start,
'current_period_end': subscription_result.current_period_end,
'created_at': datetime.now(timezone.utc),
'next_billing_date': subscription_result.current_period_end,
'trial_period_days': trial_period_days
# Get current subscription
subscription = await self.subscription_repo.get_by_tenant_id(str(tenant_uuid))
if not subscription:
raise ValidationError(f"Subscription not found for tenant {tenant_id}")
# Update local subscription record
update_data = {
'plan_id': new_plan,
'status': new_status,
'current_period_start': new_period_start,
'current_period_end': new_period_end,
'updated_at': datetime.now(timezone.utc)
}
created_subscription = await self.subscription_repo.create(subscription_data)
logger.info("subscription_created",
tenant_id=tenant_id,
subscription_id=subscription_result.id,
plan=plan)
updated_subscription = await self.subscription_repo.update(str(subscription.id), update_data)
# Invalidate subscription cache
await self._invalidate_cache(tenant_id)
logger.info(
"subscription_plan_record_updated",
tenant_id=str(tenant_id),
old_plan=subscription.plan,
new_plan=new_plan,
proration_amount=proration_details.get("net_amount", 0) if proration_details else 0
)
return {
"success": True,
"subscription_id": subscription_result.id,
"status": subscription_result.status,
"plan": plan,
"current_period_end": subscription_result.current_period_end.isoformat()
"message": f"Subscription plan record updated to {new_plan}",
"old_plan": subscription.plan,
"new_plan": new_plan,
"proration_details": proration_details,
"new_status": new_status,
"new_period_end": new_period_end.isoformat()
}
except ValidationError as ve:
logger.error("create_subscription_validation_failed",
logger.error("update_subscription_plan_record_validation_failed",
error=str(ve), tenant_id=tenant_id)
raise ve
except Exception as e:
logger.error("create_subscription_failed", error=str(e), tenant_id=tenant_id)
raise DatabaseError(f"Failed to create subscription: {str(e)}")
logger.error("update_subscription_plan_record_failed", error=str(e), tenant_id=tenant_id)
raise DatabaseError(f"Failed to update subscription plan record: {str(e)}")
async def get_subscription_by_tenant_id(
async def update_billing_cycle_record(
self,
tenant_id: str
) -> Optional[Subscription]:
tenant_id: str,
new_billing_cycle: str,
new_status: str,
new_period_start: datetime,
new_period_end: datetime,
current_plan: str,
proration_details: Optional[Dict[str, Any]] = None
) -> Dict[str, Any]:
"""
Get subscription by tenant ID
Update local billing cycle record in database
Args:
tenant_id: Tenant ID
new_billing_cycle: New billing cycle ('monthly' or 'yearly')
new_status: New subscription status
new_period_start: New period start date
new_period_end: New period end date
current_plan: Current plan name
proration_details: Proration details from payment provider
Returns:
Subscription object or None
Dictionary with billing cycle update results
"""
try:
tenant_uuid = UUID(tenant_id)
return await self.subscription_repo.get_by_tenant_id(str(tenant_uuid))
except Exception as e:
logger.error("get_subscription_by_tenant_id_failed",
error=str(e), tenant_id=tenant_id)
return None
async def get_subscription_by_stripe_id(
# Get current subscription
subscription = await self.subscription_repo.get_by_tenant_id(str(tenant_uuid))
if not subscription:
raise ValidationError(f"Subscription not found for tenant {tenant_id}")
# Update local subscription record
update_data = {
'status': new_status,
'current_period_start': new_period_start,
'current_period_end': new_period_end,
'updated_at': datetime.now(timezone.utc)
}
updated_subscription = await self.subscription_repo.update(str(subscription.id), update_data)
# Invalidate subscription cache
await self._invalidate_cache(tenant_id)
old_billing_cycle = getattr(subscription, 'billing_cycle', 'monthly')
logger.info(
"subscription_billing_cycle_record_updated",
tenant_id=str(tenant_id),
old_billing_cycle=old_billing_cycle,
new_billing_cycle=new_billing_cycle,
proration_amount=proration_details.get("net_amount", 0) if proration_details else 0
)
return {
"success": True,
"message": f"Billing cycle record changed to {new_billing_cycle}",
"old_billing_cycle": old_billing_cycle,
"new_billing_cycle": new_billing_cycle,
"proration_details": proration_details,
"new_status": new_status,
"new_period_end": new_period_end.isoformat()
}
except ValidationError as ve:
logger.error("change_billing_cycle_validation_failed",
error=str(ve), tenant_id=tenant_id)
raise ve
except Exception as e:
logger.error("change_billing_cycle_failed", error=str(e), tenant_id=tenant_id)
raise DatabaseError(f"Failed to change billing cycle: {str(e)}")
async def _invalidate_cache(self, tenant_id: str):
"""Helper method to invalidate subscription cache"""
try:
from app.services.subscription_cache import get_subscription_cache_service
import shared.redis_utils
redis_client = await shared.redis_utils.initialize_redis(settings.REDIS_URL)
cache_service = get_subscription_cache_service(redis_client)
await cache_service.invalidate_subscription_cache(str(tenant_id))
logger.info(
"Subscription cache invalidated",
tenant_id=str(tenant_id)
)
except Exception as cache_error:
logger.error(
"Failed to invalidate subscription cache",
tenant_id=str(tenant_id),
error=str(cache_error)
)
async def validate_subscription_change(
self,
stripe_subscription_id: str
) -> Optional[Subscription]:
tenant_id: str,
new_plan: str
) -> bool:
"""
Get subscription by Stripe subscription ID
Validate if a subscription change is allowed
Args:
stripe_subscription_id: Stripe subscription ID
tenant_id: Tenant ID
new_plan: New plan to validate
Returns:
Subscription object or None
True if change is allowed
"""
try:
return await self.subscription_repo.get_by_stripe_id(stripe_subscription_id)
subscription = await self.get_subscription_by_tenant_id(tenant_id)
if not subscription:
return False
# Can't change if already pending cancellation or inactive
if subscription.status in ['pending_cancellation', 'inactive']:
return False
return True
except Exception as e:
logger.error("get_subscription_by_stripe_id_failed",
error=str(e), stripe_subscription_id=stripe_subscription_id)
return None
logger.error("validate_subscription_change_failed",
error=str(e), tenant_id=tenant_id)
return False
# ========================================================================
# TENANT-INDEPENDENT SUBSCRIPTION METHODS (New Architecture)
# ========================================================================
async def create_tenant_independent_subscription_record(
self,
stripe_subscription_id: str,
stripe_customer_id: str,
plan: str,
status: str,
trial_period_days: Optional[int] = None,
billing_interval: str = "monthly",
user_id: str = None
) -> Subscription:
"""
Create a tenant-independent subscription record in the database
This subscription is not linked to any tenant and will be linked during onboarding
Args:
stripe_subscription_id: Stripe subscription ID
stripe_customer_id: Stripe customer ID
plan: Subscription plan
status: Subscription status
trial_period_days: Optional trial period in days
billing_interval: Billing interval (monthly or yearly)
user_id: User ID who created this subscription
Returns:
Created Subscription object
"""
try:
# Create tenant-independent subscription record
subscription_data = {
'stripe_subscription_id': stripe_subscription_id, # Stripe subscription ID
'stripe_customer_id': stripe_customer_id, # Stripe customer ID
'plan': plan, # Repository expects 'plan', not 'plan_id'
'status': status,
'created_at': datetime.now(timezone.utc),
'trial_period_days': trial_period_days,
'billing_cycle': billing_interval,
'user_id': user_id,
'is_tenant_linked': False,
'tenant_linking_status': 'pending'
}
created_subscription = await self.subscription_repo.create_tenant_independent_subscription(subscription_data)
logger.info("tenant_independent_subscription_record_created",
subscription_id=stripe_subscription_id,
user_id=user_id,
plan=plan)
return created_subscription
except ValidationError as ve:
logger.error("create_tenant_independent_subscription_record_validation_failed",
error=str(ve), user_id=user_id)
raise ve
except Exception as e:
logger.error("create_tenant_independent_subscription_record_failed",
error=str(e), user_id=user_id)
raise DatabaseError(f"Failed to create tenant-independent subscription record: {str(e)}")
async def get_pending_tenant_linking_subscriptions(self) -> List[Subscription]:
"""Get all subscriptions waiting to be linked to tenants"""
try:
return await self.subscription_repo.get_pending_tenant_linking_subscriptions()
except Exception as e:
logger.error("Failed to get pending tenant linking subscriptions", error=str(e))
raise DatabaseError(f"Failed to get pending subscriptions: {str(e)}")
async def get_pending_subscriptions_by_user(self, user_id: str) -> List[Subscription]:
"""Get pending tenant linking subscriptions for a specific user"""
try:
return await self.subscription_repo.get_pending_subscriptions_by_user(user_id)
except Exception as e:
logger.error("Failed to get pending subscriptions by user",
user_id=user_id, error=str(e))
raise DatabaseError(f"Failed to get pending subscriptions: {str(e)}")
async def link_subscription_to_tenant(
self,
subscription_id: str,
tenant_id: str,
user_id: str
) -> Subscription:
"""
Link a pending subscription to a tenant
This completes the registration flow by associating the subscription
created during registration with the tenant created during onboarding
Args:
subscription_id: Subscription ID to link
tenant_id: Tenant ID to link to
user_id: User ID performing the linking (for validation)
Returns:
Updated Subscription object
"""
try:
return await self.subscription_repo.link_subscription_to_tenant(
subscription_id, tenant_id, user_id
)
except Exception as e:
logger.error("Failed to link subscription to tenant",
subscription_id=subscription_id,
tenant_id=tenant_id,
user_id=user_id,
error=str(e))
raise DatabaseError(f"Failed to link subscription to tenant: {str(e)}")
async def cleanup_orphaned_subscriptions(self, days_old: int = 30) -> int:
"""Clean up subscriptions that were never linked to tenants"""
try:
return await self.subscription_repo.cleanup_orphaned_subscriptions(days_old)
except Exception as e:
logger.error("Failed to cleanup orphaned subscriptions", error=str(e))
raise DatabaseError(f"Failed to cleanup orphaned subscriptions: {str(e)}")

View File

@@ -150,10 +150,13 @@ class EnhancedTenantService:
default_plan=selected_plan)
# Create subscription with selected or default plan
# When tenant_id is set, is_tenant_linked must be True (database constraint)
subscription_data = {
"tenant_id": str(tenant.id),
"plan": selected_plan,
"status": "active"
"status": "active",
"is_tenant_linked": True, # Required when tenant_id is set
"tenant_linking_status": "completed" # Mark as completed since tenant is already created
}
subscription = await subscription_repo.create_subscription(subscription_data)
@@ -188,7 +191,7 @@ class EnhancedTenantService:
from shared.utils.city_normalization import normalize_city_id
from app.core.config import settings
external_client = ExternalServiceClient(settings, "tenant-service")
external_client = ExternalServiceClient(settings, "tenant")
city_id = normalize_city_id(bakery_data.city)
if city_id:
@@ -217,6 +220,24 @@ class EnhancedTenantService:
)
# Don't fail tenant creation if location-context creation fails
# Update user's tenant_id in auth service
try:
from shared.clients.auth_client import AuthServiceClient
from app.core.config import settings
auth_client = AuthServiceClient(settings)
await auth_client.update_user_tenant_id(owner_id, str(tenant.id))
logger.info("Updated user tenant_id in auth service",
user_id=owner_id,
tenant_id=str(tenant.id))
except Exception as e:
logger.error("Failed to update user tenant_id (non-blocking)",
user_id=owner_id,
tenant_id=str(tenant.id),
error=str(e))
# Don't fail tenant creation if user update fails
logger.info("Bakery created successfully",
tenant_id=tenant.id,
name=bakery_data.name,
@@ -1354,5 +1375,108 @@ class EnhancedTenantService:
return []
# ========================================================================
# TENANT-INDEPENDENT SUBSCRIPTION METHODS (New Architecture)
# ========================================================================
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
This completes the registration flow by associating the subscription
created during registration with the tenant created during onboarding
Args:
tenant_id: Tenant ID to link to
subscription_id: Subscription ID to link
user_id: User ID performing the linking (for validation)
Returns:
Dictionary with linking results
"""
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
# Legacy compatibility alias
TenantService = EnhancedTenantService

View File

@@ -232,6 +232,11 @@ def upgrade() -> None:
sa.Column('report_retention_days', sa.Integer(), nullable=True),
# Enterprise-specific limits
sa.Column('max_child_tenants', sa.Integer(), nullable=True),
# Tenant linking support
sa.Column('user_id', sa.String(length=36), nullable=True),
sa.Column('is_tenant_linked', sa.Boolean(), nullable=False, server_default='FALSE'),
sa.Column('tenant_linking_status', sa.String(length=50), nullable=True),
sa.Column('linked_at', sa.DateTime(), nullable=True),
# Features and metadata
sa.Column('features', sa.JSON(), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False, server_default=sa.text('CURRENT_TIMESTAMP')),
@@ -299,6 +304,24 @@ def upgrade() -> None:
postgresql_where=sa.text("stripe_customer_id IS NOT NULL")
)
# Index 7: User ID for tenant linking
if not _index_exists(connection, 'idx_subscriptions_user_id'):
op.create_index(
'idx_subscriptions_user_id',
'subscriptions',
['user_id'],
unique=False
)
# Index 8: Tenant linking status
if not _index_exists(connection, 'idx_subscriptions_linking_status'):
op.create_index(
'idx_subscriptions_linking_status',
'subscriptions',
['tenant_linking_status'],
unique=False
)
# Create coupons table with tenant_id nullable to support system-wide coupons
op.create_table('coupons',
sa.Column('id', sa.UUID(), nullable=False),
@@ -417,6 +440,13 @@ def upgrade() -> None:
op.create_index('ix_tenant_locations_location_type', 'tenant_locations', ['location_type'])
op.create_index('ix_tenant_locations_coordinates', 'tenant_locations', ['latitude', 'longitude'])
# Add constraint to ensure data consistency for tenant linking
op.create_check_constraint(
'chk_tenant_linking',
'subscriptions',
"((is_tenant_linked = FALSE AND tenant_id IS NULL) OR (is_tenant_linked = TRUE AND tenant_id IS NOT NULL))"
)
def downgrade() -> None:
# Drop tenant_locations table
@@ -445,7 +475,12 @@ def downgrade() -> None:
op.drop_index('idx_coupon_code_active', table_name='coupons')
op.drop_table('coupons')
# Drop check constraint for tenant linking
op.drop_constraint('chk_tenant_linking', 'subscriptions', type_='check')
# Drop subscriptions table indexes first
op.drop_index('idx_subscriptions_linking_status', table_name='subscriptions')
op.drop_index('idx_subscriptions_user_id', table_name='subscriptions')
op.drop_index('idx_subscriptions_stripe_customer_id', table_name='subscriptions')
op.drop_index('idx_subscriptions_stripe_sub_id', table_name='subscriptions')
op.drop_index('idx_subscriptions_active_tenant', table_name='subscriptions')

View File

@@ -0,0 +1,352 @@
"""
Integration test for the complete subscription creation flow
Tests user registration, subscription creation, tenant creation, and linking
"""
import pytest
import asyncio
import httpx
import stripe
import os
from datetime import datetime, timezone
from typing import Dict, Any, Optional
class SubscriptionCreationFlowTester:
"""Test the complete subscription creation flow"""
def __init__(self):
self.base_url = "https://bakery-ia.local"
self.timeout = 30.0
self.test_user_email = f"test_{datetime.now().strftime('%Y%m%d%H%M%S')}@example.com"
self.test_user_password = "SecurePassword123!"
self.test_user_full_name = "Test User"
self.test_plan_id = "starter" # Valid plans: starter, professional, enterprise
self.test_payment_method_id = None # Will be created dynamically
# Initialize Stripe with API key from environment
stripe_key = os.environ.get('STRIPE_SECRET_KEY')
if stripe_key:
stripe.api_key = stripe_key
print(f"✅ Stripe initialized with test mode API key")
else:
print(f"⚠️ Warning: STRIPE_SECRET_KEY not found in environment")
# Store created resources for cleanup
self.created_user_id = None
self.created_subscription_id = None
self.created_tenant_id = None
self.created_payment_method_id = None
def _create_test_payment_method(self) -> str:
"""
Create a real Stripe test payment method using Stripe's pre-made test tokens
This simulates what happens in production when a user enters their card details
In production: Frontend uses Stripe.js to tokenize card → creates PaymentMethod
In testing: We use Stripe's pre-made test tokens (tok_visa, tok_mastercard, etc.)
See: https://stripe.com/docs/testing#cards
"""
try:
print(f"💳 Creating Stripe test payment method...")
# Use Stripe's pre-made test token tok_visa
# This is the recommended approach for testing and mimics production flow
# In production, Stripe.js creates a similar token from card details
payment_method = stripe.PaymentMethod.create(
type="card",
card={"token": "tok_visa"} # Stripe's pre-made test token
)
self.created_payment_method_id = payment_method.id
print(f"✅ Created Stripe test payment method: {payment_method.id}")
print(f" This simulates a real card in production")
return payment_method.id
except Exception as e:
print(f"❌ Failed to create payment method: {str(e)}")
print(f" Tip: Ensure raw card API is enabled in Stripe dashboard:")
print(f" https://dashboard.stripe.com/settings/integration")
raise
async def test_complete_flow(self):
"""Test the complete subscription creation flow"""
print(f"🧪 Starting subscription creation flow test for {self.test_user_email}")
try:
# Step 0: Create a real Stripe test payment method
# This is EXACTLY what happens in production when user enters card details
self.test_payment_method_id = self._create_test_payment_method()
print(f"✅ Step 0: Test payment method created")
# Step 1: Register user with subscription
user_data = await self._register_user_with_subscription()
print(f"✅ Step 1: User registered successfully - user_id: {user_data['user']['id']}")
# Step 2: Verify user was created in database
await self._verify_user_in_database(user_data['user']['id'])
print(f"✅ Step 2: User verified in database")
# Step 3: Verify subscription was created (tenant-independent)
subscription_data = await self._verify_subscription_created(user_data['user']['id'])
print(f"✅ Step 3: Tenant-independent subscription verified - subscription_id: {subscription_data['subscription_id']}")
# Step 4: Create tenant and link subscription
tenant_data = await self._create_tenant_and_link_subscription(user_data['user']['id'], subscription_data['subscription_id'])
print(f"✅ Step 4: Tenant created and subscription linked - tenant_id: {tenant_data['tenant_id']}")
# Step 5: Verify subscription is linked to tenant
await self._verify_subscription_linked_to_tenant(subscription_data['subscription_id'], tenant_data['tenant_id'])
print(f"✅ Step 5: Subscription-tenant link verified")
# Step 6: Verify tenant can access subscription
await self._verify_tenant_subscription_access(tenant_data['tenant_id'])
print(f"✅ Step 6: Tenant subscription access verified")
print(f"🎉 All tests passed! Complete flow working correctly")
return True
except Exception as e:
print(f"❌ Test failed: {str(e)}")
return False
finally:
# Cleanup (optional - comment out if you want to inspect the data)
# await self._cleanup_resources()
pass
async def _register_user_with_subscription(self) -> Dict[str, Any]:
"""Register a new user with subscription"""
url = f"{self.base_url}/api/v1/auth/register-with-subscription"
payload = {
"email": self.test_user_email,
"password": self.test_user_password,
"full_name": self.test_user_full_name,
"subscription_plan": self.test_plan_id,
"payment_method_id": self.test_payment_method_id
}
async with httpx.AsyncClient(timeout=self.timeout, verify=False) as client:
response = await client.post(url, json=payload)
if response.status_code != 200:
error_msg = f"User registration failed: {response.status_code} - {response.text}"
print(f"🚨 {error_msg}")
raise Exception(error_msg)
result = response.json()
self.created_user_id = result['user']['id']
return result
async def _verify_user_in_database(self, user_id: str):
"""Verify user was created in the database"""
# This would be a direct database query in a real test
# For now, we'll just check that the user ID is valid
if not user_id or len(user_id) != 36: # UUID should be 36 characters
raise Exception(f"Invalid user ID: {user_id}")
print(f"📋 User ID validated: {user_id}")
async def _verify_subscription_created(self, user_id: str) -> Dict[str, Any]:
"""Verify that a tenant-independent subscription was created"""
# Check the onboarding progress to see if subscription data was stored
url = f"{self.base_url}/api/v1/auth/me/onboarding/progress"
# Get access token for the user
access_token = await self._get_user_access_token()
async with httpx.AsyncClient(timeout=self.timeout, verify=False) as client:
headers = {"Authorization": f"Bearer {access_token}"}
response = await client.get(url, headers=headers)
if response.status_code != 200:
error_msg = f"Failed to get onboarding progress: {response.status_code} - {response.text}"
print(f"🚨 {error_msg}")
raise Exception(error_msg)
progress_data = response.json()
# Check if subscription data is in the progress
subscription_data = None
for step in progress_data.get('steps', []):
if step.get('step_name') == 'subscription':
subscription_data = step.get('step_data', {})
break
if not subscription_data:
raise Exception("No subscription data found in onboarding progress")
# Store subscription ID for later steps
subscription_id = subscription_data.get('subscription_id')
if not subscription_id:
raise Exception("No subscription ID found in onboarding progress")
self.created_subscription_id = subscription_id
return {
'subscription_id': subscription_id,
'plan_id': subscription_data.get('plan_id'),
'payment_method_id': subscription_data.get('payment_method_id'),
'billing_cycle': subscription_data.get('billing_cycle')
}
async def _get_user_access_token(self) -> str:
"""Get access token for the test user"""
url = f"{self.base_url}/api/v1/auth/login"
payload = {
"email": self.test_user_email,
"password": self.test_user_password
}
async with httpx.AsyncClient(timeout=self.timeout, verify=False) as client:
response = await client.post(url, json=payload)
if response.status_code != 200:
error_msg = f"User login failed: {response.status_code} - {response.text}"
print(f"🚨 {error_msg}")
raise Exception(error_msg)
result = response.json()
return result['access_token']
async def _create_tenant_and_link_subscription(self, user_id: str, subscription_id: str) -> Dict[str, Any]:
"""Create a tenant and link the subscription to it"""
# This would typically be done during the onboarding flow
# For testing purposes, we'll simulate this by calling the tenant service directly
url = f"{self.base_url}/api/v1/tenants"
# Get access token for the user
access_token = await self._get_user_access_token()
payload = {
"name": f"Test Bakery {datetime.now().strftime('%Y%m%d%H%M%S')}",
"description": "Test bakery for integration testing",
"subscription_id": subscription_id,
"user_id": user_id
}
async with httpx.AsyncClient(timeout=self.timeout, verify=False) as client:
headers = {
"Authorization": f"Bearer {access_token}",
"Content-Type": "application/json"
}
response = await client.post(url, json=payload, headers=headers)
if response.status_code != 201:
error_msg = f"Tenant creation failed: {response.status_code} - {response.text}"
print(f"🚨 {error_msg}")
raise Exception(error_msg)
result = response.json()
self.created_tenant_id = result['id']
return {
'tenant_id': result['id'],
'name': result['name'],
'status': result['status']
}
async def _verify_subscription_linked_to_tenant(self, subscription_id: str, tenant_id: str):
"""Verify that the subscription is properly linked to the tenant"""
url = f"{self.base_url}/api/v1/subscriptions/{tenant_id}/status"
# Get access token for the user
access_token = await self._get_user_access_token()
async with httpx.AsyncClient(timeout=self.timeout, verify=False) as client:
headers = {"Authorization": f"Bearer {access_token}"}
response = await client.get(url, headers=headers)
if response.status_code != 200:
error_msg = f"Failed to get subscription status: {response.status_code} - {response.text}"
print(f"🚨 {error_msg}")
raise Exception(error_msg)
subscription_status = response.json()
# Verify that the subscription is active and linked to the tenant
if subscription_status['status'] not in ['active', 'trialing']:
raise Exception(f"Subscription status is {subscription_status['status']}, expected 'active' or 'trialing'")
if subscription_status['tenant_id'] != tenant_id:
raise Exception(f"Subscription linked to wrong tenant: {subscription_status['tenant_id']} != {tenant_id}")
print(f"📋 Subscription status verified: {subscription_status['status']}")
print(f"📋 Subscription linked to tenant: {subscription_status['tenant_id']}")
async def _verify_tenant_subscription_access(self, tenant_id: str):
"""Verify that the tenant can access its subscription"""
url = f"{self.base_url}/api/v1/subscriptions/{tenant_id}/active"
# Get access token for the user
access_token = await self._get_user_access_token()
async with httpx.AsyncClient(timeout=self.timeout, verify=False) as client:
headers = {"Authorization": f"Bearer {access_token}"}
response = await client.get(url, headers=headers)
if response.status_code != 200:
error_msg = f"Failed to get active subscription: {response.status_code} - {response.text}"
print(f"🚨 {error_msg}")
raise Exception(error_msg)
subscription_data = response.json()
# Verify that the subscription data is complete
required_fields = ['id', 'status', 'plan', 'current_period_start', 'current_period_end']
for field in required_fields:
if field not in subscription_data:
raise Exception(f"Missing required field in subscription data: {field}")
print(f"📋 Active subscription verified for tenant {tenant_id}")
print(f"📋 Subscription plan: {subscription_data['plan']}")
print(f"📋 Subscription status: {subscription_data['status']}")
async def _cleanup_resources(self):
"""Clean up test resources"""
print("🧹 Cleaning up test resources...")
# In a real test, you would delete the user, tenant, and subscription
# For now, we'll just print what would be cleaned up
print(f"Would delete user: {self.created_user_id}")
print(f"Would delete subscription: {self.created_subscription_id}")
print(f"Would delete tenant: {self.created_tenant_id}")
@pytest.mark.asyncio
async def test_subscription_creation_flow():
"""Test the complete subscription creation flow"""
tester = SubscriptionCreationFlowTester()
result = await tester.test_complete_flow()
assert result is True, "Subscription creation flow test failed"
if __name__ == "__main__":
# Run the test
import asyncio
print("🚀 Starting subscription creation flow integration test...")
# Create and run the test
tester = SubscriptionCreationFlowTester()
# Run the test
success = asyncio.run(tester.test_complete_flow())
if success:
print("\n🎉 Integration test completed successfully!")
print("\nTest Summary:")
print(f"✅ User registration with subscription")
print(f"✅ User verification in database")
print(f"✅ Tenant-independent subscription creation")
print(f"✅ Tenant creation and subscription linking")
print(f"✅ Subscription-tenant link verification")
print(f"✅ Tenant subscription access verification")
print(f"\nAll components working together correctly! 🚀")
else:
print("\n❌ Integration test failed!")
exit(1)

View File

@@ -65,7 +65,7 @@ class AlertProcessorClient(BaseServiceClient):
result = await self.post(
f"tenants/{tenant_id}/alerts/acknowledge-by-metadata",
tenant_id=str(tenant_id),
json=payload
data=payload
)
if result and result.get("success"):
@@ -127,7 +127,7 @@ class AlertProcessorClient(BaseServiceClient):
result = await self.post(
f"tenants/{tenant_id}/alerts/resolve-by-metadata",
tenant_id=str(tenant_id),
json=payload
data=payload
)
if result and result.get("success"):

View File

@@ -182,4 +182,82 @@ class AuthServiceClient(BaseServiceClient):
email=user_data.get("email"),
error=str(e)
)
raise
raise
async def get_user_details(self, user_id: str) -> Optional[Dict[str, Any]]:
"""
Get detailed user information including payment details
Args:
user_id: User ID to fetch details for
Returns:
Dict with user details including:
- id, email, full_name, is_active, is_verified
- phone, language, timezone, role
- payment_customer_id, default_payment_method_id
- created_at, last_login, etc.
Returns None if user not found or request fails
"""
try:
logger.info("Fetching user details from auth service",
user_id=user_id)
result = await self.get(f"/users/{user_id}")
if result and result.get("id"):
logger.info("Successfully retrieved user details",
user_id=user_id,
email=result.get("email"),
has_payment_info="payment_customer_id" in result)
return result
else:
logger.warning("No user details found",
user_id=user_id)
return None
except Exception as e:
logger.error("Failed to get user details from auth service",
user_id=user_id,
error=str(e))
return None
async def update_user_tenant_id(self, user_id: str, tenant_id: str) -> bool:
"""
Update the user's tenant_id after tenant registration
Args:
user_id: User ID to update
tenant_id: Tenant ID to link to the user
Returns:
True if successful, False otherwise
"""
try:
logger.info("Updating user tenant_id",
user_id=user_id,
tenant_id=tenant_id)
result = await self.patch(
f"/users/{user_id}/tenant",
{"tenant_id": tenant_id}
)
if result:
logger.info("Successfully updated user tenant_id",
user_id=user_id,
tenant_id=tenant_id)
return True
else:
logger.warning("Failed to update user tenant_id",
user_id=user_id,
tenant_id=tenant_id)
return False
except Exception as e:
logger.error("Error updating user tenant_id",
user_id=user_id,
tenant_id=tenant_id,
error=str(e))
return False

View File

@@ -428,7 +428,11 @@ class BaseServiceClient(ABC):
async def put(self, endpoint: str, data: Dict[str, Any], tenant_id: Optional[str] = None) -> Optional[Dict[str, Any]]:
"""Make a PUT request"""
return await self._make_request("PUT", endpoint, tenant_id=tenant_id, data=data)
async def patch(self, endpoint: str, data: Dict[str, Any], tenant_id: Optional[str] = None) -> Optional[Dict[str, Any]]:
"""Make a PATCH request"""
return await self._make_request("PATCH", endpoint, tenant_id=tenant_id, data=data)
async def delete(self, endpoint: str, tenant_id: Optional[str] = None) -> Optional[Dict[str, Any]]:
"""Make a DELETE request"""
return await self._make_request("DELETE", endpoint, tenant_id=tenant_id)

View File

@@ -307,7 +307,7 @@ class ExternalServiceClient(BaseServiceClient):
"POST",
"external/location-context",
tenant_id=tenant_id,
json=payload,
data=payload,
timeout=10.0
)

View File

@@ -860,9 +860,9 @@ class InventoryServiceClient(BaseServiceClient):
# Factory function for dependency injection
def create_inventory_client(config: BaseServiceSettings) -> InventoryServiceClient:
def create_inventory_client(config: BaseServiceSettings, service_name: str = "unknown") -> InventoryServiceClient:
"""Create inventory service client instance"""
return InventoryServiceClient(config)
return InventoryServiceClient(config, calling_service_name=service_name)
# Convenience function for quick access (requires config to be passed)

View File

@@ -36,6 +36,8 @@ class Subscription:
current_period_start: datetime
current_period_end: datetime
created_at: datetime
billing_cycle_anchor: Optional[datetime] = None
cancel_at_period_end: Optional[bool] = None
@dataclass
@@ -81,9 +83,17 @@ class PaymentProvider(abc.ABC):
pass
@abc.abstractmethod
async def cancel_subscription(self, subscription_id: str) -> Subscription:
async def cancel_subscription(
self,
subscription_id: str,
cancel_at_period_end: bool = True
) -> Subscription:
"""
Cancel a subscription
Args:
subscription_id: Subscription ID to cancel
cancel_at_period_end: If True, cancel at end of billing period. Default True.
"""
pass

View File

@@ -289,6 +289,6 @@ class RecipesServiceClient(BaseServiceClient):
# Factory function for dependency injection
def create_recipes_client(config: BaseServiceSettings) -> RecipesServiceClient:
def create_recipes_client(config: BaseServiceSettings, service_name: str = "unknown") -> RecipesServiceClient:
"""Create recipes service client instance"""
return RecipesServiceClient(config)
return RecipesServiceClient(config, calling_service_name=service_name)

View File

@@ -76,16 +76,24 @@ class StripeProvider(PaymentProvider):
plan_id=plan_id,
payment_method_id=payment_method_id)
# Attach payment method to customer with idempotency
stripe.PaymentMethod.attach(
payment_method_id,
customer=customer_id,
idempotency_key=payment_method_idempotency_key
)
logger.info("Payment method attached to customer",
customer_id=customer_id,
payment_method_id=payment_method_id)
# Attach payment method to customer with idempotency and error handling
try:
stripe.PaymentMethod.attach(
payment_method_id,
customer=customer_id,
idempotency_key=payment_method_idempotency_key
)
logger.info("Payment method attached to customer",
customer_id=customer_id,
payment_method_id=payment_method_id)
except stripe.error.InvalidRequestError as e:
# Payment method may already be attached
if 'already been attached' in str(e):
logger.warning("Payment method already attached to customer",
customer_id=customer_id,
payment_method_id=payment_method_id)
else:
raise
# Set customer's default payment method with idempotency
stripe.Customer.modify(
@@ -114,19 +122,36 @@ class StripeProvider(PaymentProvider):
trial_period_days=trial_period_days)
stripe_subscription = stripe.Subscription.create(**subscription_params)
logger.info("Stripe subscription created successfully",
subscription_id=stripe_subscription.id,
status=stripe_subscription.status,
current_period_end=stripe_subscription.current_period_end)
# Handle period dates for trial vs active subscriptions
# During trial: current_period_* fields are only in subscription items, not root
# After trial: current_period_* fields are at root level
if stripe_subscription.status == 'trialing' and stripe_subscription.items and stripe_subscription.items.data:
# For trial subscriptions, get period from first subscription item
first_item = stripe_subscription.items.data[0]
current_period_start = first_item.current_period_start
current_period_end = first_item.current_period_end
logger.info("Stripe trial subscription created successfully",
subscription_id=stripe_subscription.id,
status=stripe_subscription.status,
trial_end=stripe_subscription.trial_end,
current_period_end=current_period_end)
else:
# For active subscriptions, get period from root level
current_period_start = stripe_subscription.current_period_start
current_period_end = stripe_subscription.current_period_end
logger.info("Stripe subscription created successfully",
subscription_id=stripe_subscription.id,
status=stripe_subscription.status,
current_period_end=current_period_end)
return Subscription(
id=stripe_subscription.id,
customer_id=stripe_subscription.customer,
plan_id=plan_id, # Using the price ID as plan_id
status=stripe_subscription.status,
current_period_start=datetime.fromtimestamp(stripe_subscription.current_period_start),
current_period_end=datetime.fromtimestamp(stripe_subscription.current_period_end),
current_period_start=datetime.fromtimestamp(current_period_start),
current_period_end=datetime.fromtimestamp(current_period_end),
created_at=datetime.fromtimestamp(stripe_subscription.created)
)
except stripe.error.CardError as e:
@@ -155,12 +180,24 @@ class StripeProvider(PaymentProvider):
Update the payment method for a customer in Stripe
"""
try:
# Attach payment method to customer
stripe.PaymentMethod.attach(
payment_method_id,
customer=customer_id,
)
# Attach payment method to customer with error handling
try:
stripe.PaymentMethod.attach(
payment_method_id,
customer=customer_id,
)
logger.info("Payment method attached for update",
customer_id=customer_id,
payment_method_id=payment_method_id)
except stripe.error.InvalidRequestError as e:
# Payment method may already be attached
if 'already been attached' in str(e):
logger.warning("Payment method already attached, skipping attach",
customer_id=customer_id,
payment_method_id=payment_method_id)
else:
raise
# Set as default payment method
stripe.Customer.modify(
customer_id,
@@ -183,20 +220,54 @@ class StripeProvider(PaymentProvider):
logger.error("Failed to update Stripe payment method", error=str(e))
raise e
async def cancel_subscription(self, subscription_id: str) -> Subscription:
async def cancel_subscription(
self,
subscription_id: str,
cancel_at_period_end: bool = True
) -> Subscription:
"""
Cancel a subscription in Stripe
Args:
subscription_id: Stripe subscription ID
cancel_at_period_end: If True, subscription continues until end of billing period.
If False, cancels immediately.
Returns:
Updated Subscription object
"""
try:
stripe_subscription = stripe.Subscription.delete(subscription_id)
if cancel_at_period_end:
# Cancel at end of billing period (graceful cancellation)
stripe_subscription = stripe.Subscription.modify(
subscription_id,
cancel_at_period_end=True
)
logger.info("Subscription set to cancel at period end",
subscription_id=subscription_id,
cancel_at=stripe_subscription.trial_end if stripe_subscription.status == 'trialing' else stripe_subscription.current_period_end)
else:
# Cancel immediately
stripe_subscription = stripe.Subscription.delete(subscription_id)
logger.info("Subscription cancelled immediately",
subscription_id=subscription_id)
# Handle period dates for trial vs active subscriptions
if stripe_subscription.status == 'trialing' and stripe_subscription.items and stripe_subscription.items.data:
first_item = stripe_subscription.items.data[0]
current_period_start = first_item.current_period_start
current_period_end = first_item.current_period_end
else:
current_period_start = stripe_subscription.current_period_start
current_period_end = stripe_subscription.current_period_end
return Subscription(
id=stripe_subscription.id,
customer_id=stripe_subscription.customer,
plan_id=subscription_id, # This would need to be retrieved differently in practice
plan_id=subscription_id,
status=stripe_subscription.status,
current_period_start=datetime.fromtimestamp(stripe_subscription.current_period_start),
current_period_end=datetime.fromtimestamp(stripe_subscription.current_period_end),
current_period_start=datetime.fromtimestamp(current_period_start),
current_period_end=datetime.fromtimestamp(current_period_end),
created_at=datetime.fromtimestamp(stripe_subscription.created)
)
except stripe.error.StripeError as e:
@@ -242,19 +313,291 @@ class StripeProvider(PaymentProvider):
"""
try:
stripe_subscription = stripe.Subscription.retrieve(subscription_id)
# Get the actual plan ID from the subscription items
plan_id = subscription_id # Default fallback
if stripe_subscription.items and stripe_subscription.items.data:
plan_id = stripe_subscription.items.data[0].price.id
# Handle period dates for trial vs active subscriptions
# During trial: current_period_* fields are only in subscription items, not root
# After trial: current_period_* fields are at root level
if stripe_subscription.status == 'trialing' and stripe_subscription.items and stripe_subscription.items.data:
# For trial subscriptions, get period from first subscription item
first_item = stripe_subscription.items.data[0]
current_period_start = first_item.current_period_start
current_period_end = first_item.current_period_end
else:
# For active subscriptions, get period from root level
current_period_start = stripe_subscription.current_period_start
current_period_end = stripe_subscription.current_period_end
return Subscription(
id=stripe_subscription.id,
customer_id=stripe_subscription.customer,
plan_id=subscription_id, # This would need to be retrieved differently in practice
plan_id=plan_id,
status=stripe_subscription.status,
current_period_start=datetime.fromtimestamp(stripe_subscription.current_period_start),
current_period_end=datetime.fromtimestamp(stripe_subscription.current_period_end),
created_at=datetime.fromtimestamp(stripe_subscription.created)
current_period_start=datetime.fromtimestamp(current_period_start),
current_period_end=datetime.fromtimestamp(current_period_end),
created_at=datetime.fromtimestamp(stripe_subscription.created),
billing_cycle_anchor=datetime.fromtimestamp(stripe_subscription.billing_cycle_anchor) if stripe_subscription.billing_cycle_anchor else None,
cancel_at_period_end=stripe_subscription.cancel_at_period_end
)
except stripe.error.StripeError as e:
logger.error("Failed to retrieve Stripe subscription", error=str(e))
raise e
async def update_subscription(
self,
subscription_id: str,
new_price_id: str,
proration_behavior: str = "create_prorations",
billing_cycle_anchor: str = "unchanged",
payment_behavior: str = "error_if_incomplete",
immediate_change: bool = False
) -> Subscription:
"""
Update a subscription in Stripe with proration support
Args:
subscription_id: Stripe subscription ID
new_price_id: New Stripe price ID to switch to
proration_behavior: How to handle prorations ('create_prorations', 'none', 'always_invoice')
billing_cycle_anchor: When to apply changes ('unchanged', 'now')
payment_behavior: Payment behavior ('error_if_incomplete', 'allow_incomplete')
immediate_change: Whether to apply changes immediately or at period end
Returns:
Updated Subscription object
"""
try:
logger.info("Updating Stripe subscription",
subscription_id=subscription_id,
new_price_id=new_price_id,
proration_behavior=proration_behavior,
immediate_change=immediate_change)
# Get current subscription to preserve settings
current_subscription = stripe.Subscription.retrieve(subscription_id)
# Build update parameters
update_params = {
'items': [{
'id': current_subscription.items.data[0].id,
'price': new_price_id,
}],
'proration_behavior': proration_behavior,
'billing_cycle_anchor': billing_cycle_anchor,
'payment_behavior': payment_behavior,
'expand': ['latest_invoice.payment_intent']
}
# If not immediate change, set cancel_at_period_end to False
# and let Stripe handle the transition
if not immediate_change:
update_params['cancel_at_period_end'] = False
update_params['proration_behavior'] = 'none' # No proration for end-of-period changes
# Update the subscription
updated_subscription = stripe.Subscription.modify(
subscription_id,
**update_params
)
logger.info("Stripe subscription updated successfully",
subscription_id=updated_subscription.id,
new_price_id=new_price_id,
status=updated_subscription.status)
# Get the actual plan ID from the subscription items
plan_id = new_price_id
if updated_subscription.items and updated_subscription.items.data:
plan_id = updated_subscription.items.data[0].price.id
# Handle period dates for trial vs active subscriptions
if updated_subscription.status == 'trialing' and updated_subscription.items and updated_subscription.items.data:
first_item = updated_subscription.items.data[0]
current_period_start = first_item.current_period_start
current_period_end = first_item.current_period_end
else:
current_period_start = updated_subscription.current_period_start
current_period_end = updated_subscription.current_period_end
return Subscription(
id=updated_subscription.id,
customer_id=updated_subscription.customer,
plan_id=plan_id,
status=updated_subscription.status,
current_period_start=datetime.fromtimestamp(current_period_start),
current_period_end=datetime.fromtimestamp(current_period_end),
created_at=datetime.fromtimestamp(updated_subscription.created),
billing_cycle_anchor=datetime.fromtimestamp(updated_subscription.billing_cycle_anchor) if updated_subscription.billing_cycle_anchor else None,
cancel_at_period_end=updated_subscription.cancel_at_period_end
)
except stripe.error.StripeError as e:
logger.error("Failed to update Stripe subscription",
error=str(e),
subscription_id=subscription_id,
new_price_id=new_price_id)
raise e
async def calculate_proration(
self,
subscription_id: str,
new_price_id: str,
proration_behavior: str = "create_prorations"
) -> Dict[str, Any]:
"""
Calculate proration amounts for a subscription change
Args:
subscription_id: Stripe subscription ID
new_price_id: New Stripe price ID
proration_behavior: Proration behavior to use
Returns:
Dictionary with proration details including amount, currency, and description
"""
try:
logger.info("Calculating proration for subscription change",
subscription_id=subscription_id,
new_price_id=new_price_id)
# Get current subscription
current_subscription = stripe.Subscription.retrieve(subscription_id)
current_price_id = current_subscription.items.data[0].price.id
# Get current and new prices
current_price = stripe.Price.retrieve(current_price_id)
new_price = stripe.Price.retrieve(new_price_id)
# Calculate time remaining in current billing period
current_period_end = datetime.fromtimestamp(current_subscription.current_period_end)
current_period_start = datetime.fromtimestamp(current_subscription.current_period_start)
now = datetime.now(timezone.utc)
total_period_days = (current_period_end - current_period_start).days
remaining_days = (current_period_end - now).days
used_days = (now - current_period_start).days
# Calculate prorated amounts
current_price_amount = current_price.unit_amount / 100.0 # Convert from cents
new_price_amount = new_price.unit_amount / 100.0
# Calculate daily rates
current_daily_rate = current_price_amount / total_period_days
new_daily_rate = new_price_amount / total_period_days
# Calculate proration based on behavior
if proration_behavior == "create_prorations":
# Calculate credit for unused time on current plan
unused_current_amount = current_daily_rate * remaining_days
# Calculate charge for remaining time on new plan
prorated_new_amount = new_daily_rate * remaining_days
# Net amount (could be positive or negative)
net_amount = prorated_new_amount - unused_current_amount
return {
"current_price_amount": current_price_amount,
"new_price_amount": new_price_amount,
"unused_current_amount": unused_current_amount,
"prorated_new_amount": prorated_new_amount,
"net_amount": net_amount,
"currency": current_price.currency.upper(),
"remaining_days": remaining_days,
"used_days": used_days,
"total_period_days": total_period_days,
"description": f"Proration for changing from {current_price_id} to {new_price_id}",
"is_credit": net_amount < 0
}
elif proration_behavior == "none":
return {
"current_price_amount": current_price_amount,
"new_price_amount": new_price_amount,
"net_amount": 0,
"currency": current_price.currency.upper(),
"description": "No proration - changes apply at period end",
"is_credit": False
}
else:
return {
"current_price_amount": current_price_amount,
"new_price_amount": new_price_amount,
"net_amount": new_price_amount - current_price_amount,
"currency": current_price.currency.upper(),
"description": "Full amount difference - immediate billing",
"is_credit": False
}
except stripe.error.StripeError as e:
logger.error("Failed to calculate proration",
error=str(e),
subscription_id=subscription_id,
new_price_id=new_price_id)
raise e
async def change_billing_cycle(
self,
subscription_id: str,
new_billing_cycle: str,
proration_behavior: str = "create_prorations"
) -> Subscription:
"""
Change billing cycle (monthly ↔ yearly) for a subscription
Args:
subscription_id: Stripe subscription ID
new_billing_cycle: New billing cycle ('monthly' or 'yearly')
proration_behavior: Proration behavior to use
Returns:
Updated Subscription object
"""
try:
logger.info("Changing billing cycle for subscription",
subscription_id=subscription_id,
new_billing_cycle=new_billing_cycle)
# Get current subscription
current_subscription = stripe.Subscription.retrieve(subscription_id)
current_price_id = current_subscription.items.data[0].price.id
# Get current price to determine the plan
current_price = stripe.Price.retrieve(current_price_id)
product_id = current_price.product
# Find the corresponding price for the new billing cycle
# This assumes you have price IDs set up for both monthly and yearly
# You would need to map this based on your product catalog
prices = stripe.Price.list(product=product_id, active=True)
new_price_id = None
for price in prices:
if price.recurring and price.recurring.interval == new_billing_cycle:
new_price_id = price.id
break
if not new_price_id:
raise ValueError(f"No {new_billing_cycle} price found for product {product_id}")
# Update the subscription with the new price
return await self.update_subscription(
subscription_id,
new_price_id,
proration_behavior=proration_behavior,
billing_cycle_anchor="now",
immediate_change=True
)
except stripe.error.StripeError as e:
logger.error("Failed to change billing cycle",
error=str(e),
subscription_id=subscription_id,
new_billing_cycle=new_billing_cycle)
raise e
async def get_customer(self, customer_id: str) -> PaymentCustomer:
"""

View File

@@ -291,6 +291,6 @@ class SuppliersServiceClient(BaseServiceClient):
# Factory function for dependency injection
def create_suppliers_client(config: BaseServiceSettings) -> SuppliersServiceClient:
def create_suppliers_client(config: BaseServiceSettings, service_name: str = "unknown") -> SuppliersServiceClient:
"""Create suppliers service client instance"""
return SuppliersServiceClient(config)
return SuppliersServiceClient(config, calling_service_name=service_name)

View File

@@ -420,6 +420,207 @@ class TenantServiceClient(BaseServiceClient):
logger.error("Tenant service health check failed", error=str(e))
return False
# ================================================================
# PAYMENT CUSTOMER MANAGEMENT
# ================================================================
async def create_payment_customer(
self,
user_data: Dict[str, Any],
payment_method_id: Optional[str] = None
) -> Optional[Dict[str, Any]]:
"""
Create a payment customer for a user
This method creates a payment customer record in the tenant service
during user registration or onboarding. It handles the integration
with payment providers and returns the payment customer details.
Args:
user_data: User data including:
- user_id: User ID (required)
- email: User email (required)
- full_name: User full name (required)
- name: User name (optional, defaults to full_name)
payment_method_id: Optional payment method ID to attach to the customer
Returns:
Dict with payment customer details including:
- success: boolean
- payment_customer_id: string
- payment_method: dict with payment method details
- customer: dict with customer details
Returns None if creation fails
"""
try:
logger.info("Creating payment customer via tenant service",
user_id=user_data.get('user_id'),
email=user_data.get('email'))
# Prepare data for tenant service
tenant_data = {
"user_data": user_data,
"payment_method_id": payment_method_id
}
# Call tenant service endpoint
result = await self.post("/payment-customers/create", tenant_data)
if result and result.get("success"):
logger.info("Payment customer created successfully via tenant service",
user_id=user_data.get('user_id'),
payment_customer_id=result.get('payment_customer_id'))
return result
else:
logger.error("Payment customer creation failed via tenant service",
user_id=user_data.get('user_id'),
error=result.get('detail') if result else 'No detail provided')
return None
except Exception as e:
logger.error("Failed to create payment customer via tenant service",
user_id=user_data.get('user_id'),
error=str(e))
return None
async def create_subscription_for_registration(
self,
user_data: Dict[str, Any],
plan_id: str,
payment_method_id: str,
billing_cycle: str = "monthly",
coupon_code: Optional[str] = None
) -> Optional[Dict[str, Any]]:
"""
Create a tenant-independent subscription during user registration
This method creates a subscription that is not linked to any tenant yet.
The subscription will be linked to a tenant during the onboarding flow
when the user creates their bakery/tenant.
Args:
user_data: User data including:
- user_id: User ID (required)
- email: User email (required)
- full_name: User full name (required)
- name: User name (optional, defaults to full_name)
plan_id: Subscription plan ID (starter, professional, enterprise)
payment_method_id: Stripe payment method ID
billing_cycle: Billing cycle (monthly or yearly), defaults to monthly
coupon_code: Optional coupon code for discounts/trials
Returns:
Dict with subscription creation results including:
- success: boolean
- subscription_id: string (Stripe subscription ID)
- customer_id: string (Stripe customer ID)
- status: string (subscription status)
- plan: string (plan name)
- billing_cycle: string (billing interval)
- trial_period_days: int (if trial applied)
- coupon_applied: boolean
Returns None if creation fails
"""
try:
logger.info("Creating tenant-independent subscription for registration",
user_id=user_data.get('user_id'),
plan_id=plan_id,
billing_cycle=billing_cycle)
# Prepare data for tenant service
subscription_data = {
"user_data": user_data,
"plan_id": plan_id,
"payment_method_id": payment_method_id,
"billing_interval": billing_cycle,
"coupon_code": coupon_code
}
# Call tenant service endpoint
result = await self.post("/subscriptions/create-for-registration", subscription_data)
if result and result.get("success"):
data = result.get("data", {})
logger.info("Tenant-independent subscription created successfully",
user_id=user_data.get('user_id'),
subscription_id=data.get('subscription_id'),
plan=data.get('plan'))
return data
else:
logger.error("Subscription creation failed via tenant service",
user_id=user_data.get('user_id'),
error=result.get('detail') if result else 'No detail provided')
return None
except Exception as e:
logger.error("Failed to create subscription for registration via tenant service",
user_id=user_data.get('user_id'),
plan_id=plan_id,
error=str(e))
return None
async def link_subscription_to_tenant(
self,
tenant_id: str,
subscription_id: str,
user_id: str
) -> Optional[Dict[str, Any]]:
"""
Link a pending subscription to a tenant
This completes the registration flow by associating the subscription
created during registration with the tenant created during onboarding.
Args:
tenant_id: Tenant ID to link subscription to
subscription_id: Subscription ID (from registration)
user_id: User ID performing the linking (for validation)
Returns:
Dict with linking results:
- success: boolean
- tenant_id: string
- subscription_id: string
- status: string
Returns None if linking fails
"""
try:
logger.info("Linking subscription to tenant",
tenant_id=tenant_id,
subscription_id=subscription_id,
user_id=user_id)
# Prepare data for tenant service
linking_data = {
"subscription_id": subscription_id,
"user_id": user_id
}
# Call tenant service endpoint
result = await self.post(
f"/tenants/{tenant_id}/link-subscription",
linking_data
)
if result and result.get("success"):
logger.info("Subscription linked to tenant successfully",
tenant_id=tenant_id,
subscription_id=subscription_id)
return result
else:
logger.error("Subscription linking failed via tenant service",
tenant_id=tenant_id,
subscription_id=subscription_id,
error=result.get('detail') if result else 'No detail provided')
return None
except Exception as e:
logger.error("Failed to link subscription to tenant via tenant service",
tenant_id=tenant_id,
subscription_id=subscription_id,
error=str(e))
return None
# Factory function for dependency injection
def create_tenant_client(config: BaseServiceSettings) -> TenantServiceClient:

View File

@@ -3,7 +3,7 @@ Coupon system for subscription discounts and promotions.
Supports trial extensions, percentage discounts, and fixed amount discounts.
"""
from dataclasses import dataclass
from datetime import datetime
from datetime import datetime, timezone
from enum import Enum
from typing import Optional
@@ -32,7 +32,7 @@ class Coupon:
def is_valid(self) -> bool:
"""Check if coupon is currently valid"""
now = datetime.utcnow()
now = datetime.now(timezone.utc)
# Check if active
if not self.active:
@@ -60,7 +60,7 @@ class Coupon:
if not self.active:
return False, "Coupon is inactive"
now = datetime.utcnow()
now = datetime.now(timezone.utc)
if now < self.valid_from:
return False, "Coupon is not yet valid"
@@ -98,7 +98,7 @@ def calculate_trial_end_date(base_trial_days: int, extension_days: int) -> datet
"""Calculate trial end date with coupon extension"""
from datetime import timedelta
total_days = base_trial_days + extension_days
return datetime.utcnow() + timedelta(days=total_days)
return datetime.now(timezone.utc) + timedelta(days=total_days)
def format_discount_description(coupon: Coupon) -> str: