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

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):
"""