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

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