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